diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/AuthenticatedActionsHandler.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/AuthenticatedActionsHandler.java
index 2b218733fb..8197c38a92 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/AuthenticatedActionsHandler.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/AuthenticatedActionsHandler.java
@@ -1,6 +1,6 @@
/*
* JBoss, Home of Professional Open Source.
- * Copyright 2021 Red Hat, Inc., and individual contributors
+ * Copyright 2024 Red Hat, Inc., and individual contributors
* as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -36,6 +36,7 @@
* @author Farah Juma
*/
public class AuthenticatedActionsHandler {
+
private OidcClientConfiguration deployment;
private OidcHttpFacade facade;
@@ -52,6 +53,7 @@ public boolean handledRequest() {
queryBearerToken();
return true;
}
+
return false;
}
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/ElytronMessages.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/ElytronMessages.java
index e836cc3b46..7b54b31846 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/ElytronMessages.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/ElytronMessages.java
@@ -278,5 +278,12 @@ interface ElytronMessages extends BasicLogger {
@Message(id = 23070, value = "Authentication request format must be one of the following: oauth2, request, request_uri.")
RuntimeException invalidAuthenticationRequestFormat();
+
+ @Message(id = 23071, value = "Invalid logout output: %s is not a valid value for %s")
+ RuntimeException invalidLogoutPath(String pathValue, String pathName);
+
+ @Message(id = 23072, value = "Invalid %s: %s the value can not be identical to %s: %s")
+ RuntimeException invalidLogoutCallbackPath(String callbackPathTitle, String callbackPathValue,
+ String logoutPathTitle, String logoutPathName);
}
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/IDToken.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/IDToken.java
index d40be6bfce..2c733cdacd 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/IDToken.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/IDToken.java
@@ -53,6 +53,7 @@ public class IDToken extends JsonWebToken {
public static final String CLAIMS_LOCALES = "claims_locales";
public static final String ACR = "acr";
public static final String S_HASH = "s_hash";
+ public static final String SID = "sid";
/**
* Construct a new instance.
@@ -228,4 +229,12 @@ public String getAcr() {
return getClaimValueAsString(ACR);
}
+ /**
+ * Get the sid claim.
+ *
+ * @return the sid claim
+ */
+ public String getSid() {
+ return getClaimValueAsString(SID);
+ }
}
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/LogoutHandler.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/LogoutHandler.java
new file mode 100644
index 0000000000..23e719d12b
--- /dev/null
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/LogoutHandler.java
@@ -0,0 +1,270 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ * Copyright 2024 Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.wildfly.security.http.oidc;
+
+import static java.util.Collections.synchronizedMap;
+import static org.wildfly.security.http.oidc.ElytronMessages.log;
+
+import java.net.URISyntaxException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.http.HttpStatus;
+import org.apache.http.client.utils.URIBuilder;
+import org.jose4j.jwt.JwtClaims;
+import org.wildfly.security.http.HttpConstants;
+import org.wildfly.security.http.HttpScope;
+import org.wildfly.security.http.Scope;
+import org.wildfly.security.http.oidc.OidcHttpFacade.Request;
+
+/**
+ * @author Pedro Igor
+ */
+final class LogoutHandler {
+
+ private static final String POST_LOGOUT_REDIRECT_URI_PARAM = "post_logout_redirect_uri";
+ private static final String ID_TOKEN_HINT_PARAM = "id_token_hint";
+ private static final String LOGOUT_TOKEN_PARAM = "logout_token";
+ private static final String LOGOUT_TOKEN_TYPE = "Logout";
+ private static final String CLIENT_ID_SID_SEPARATOR = "-";
+ private static final String SID = "sid";
+ private static final String ISS = "iss";
+
+ /**
+ * A bounded map to store sessions marked for invalidation after receiving logout requests through the back-channel
+ */
+ private Map sessionsMarkedForInvalidation = synchronizedMap(new LinkedHashMap(16, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ boolean remove = sessionsMarkedForInvalidation.size() > eldest.getValue().getLogoutSessionWaitingLimit();
+
+ if (remove) {
+ log.debugf("Limit [%s] reached for sessions waiting [%s] for logout", eldest.getValue().getLogoutSessionWaitingLimit(), sessionsMarkedForInvalidation.size());
+ }
+
+ return remove;
+ }
+ });
+
+ boolean tryLogout(OidcHttpFacade facade) {
+ log.trace("tryLogout entered");
+ RefreshableOidcSecurityContext securityContext = getSecurityContext(facade);
+ if (securityContext == null) {
+ // no active session
+ log.trace("tryLogout securityContext == null");
+ return false;
+ }
+
+ if (isRpInitiatedLogoutPath(facade)) {
+ log.trace("isRpInitiatedLogoutPath");
+ redirectEndSessionEndpoint(facade);
+ return true;
+ }
+
+ if (isLogoutCallbackPath(facade)) {
+ log.trace("isLogoutCallbackPath");
+ if (isFrontChannel(facade)) {
+ log.trace("isFrontChannel");
+ handleFrontChannelLogoutRequest(facade);
+ return true;
+ } else {
+ // we have an active session, should have received a GET logout request
+ facade.getResponse().setStatus(HttpStatus.SC_METHOD_NOT_ALLOWED);
+ facade.authenticationFailed();
+ }
+ }
+ return false;
+ }
+
+ boolean isSessionMarkedForInvalidation(OidcHttpFacade facade) {
+ HttpScope session = facade.getScope(Scope.SESSION);
+ if (session == null || ! session.exists()) return false;
+ RefreshableOidcSecurityContext securityContext = (RefreshableOidcSecurityContext) session.getAttachment(OidcSecurityContext.class.getName());
+ if (securityContext == null) {
+ return false;
+ }
+ IDToken idToken = securityContext.getIDToken();
+
+ if (idToken == null) {
+ return false;
+ }
+ return sessionsMarkedForInvalidation.remove(getSessionKey(facade, idToken.getSid())) != null;
+ }
+
+ private void redirectEndSessionEndpoint(OidcHttpFacade facade) {
+ RefreshableOidcSecurityContext securityContext = getSecurityContext(facade);
+ OidcClientConfiguration clientConfiguration = securityContext.getOidcClientConfiguration();
+
+ String logoutUri;
+
+ try {
+ URIBuilder redirectUriBuilder = new URIBuilder(clientConfiguration.getEndSessionEndpointUrl())
+ .addParameter(ID_TOKEN_HINT_PARAM, securityContext.getIDTokenString());
+ String postLogoutPath = clientConfiguration.getPostLogoutPath();
+ if (postLogoutPath != null) {
+ log.trace("post_logout_redirect_uri: " + postLogoutPath);
+ redirectUriBuilder.addParameter(POST_LOGOUT_REDIRECT_URI_PARAM, postLogoutPath);
+ }
+
+ logoutUri = redirectUriBuilder.build().toString();
+ log.trace("redirectEndSessionEndpoint path: " + redirectUriBuilder.toString());
+ } catch (URISyntaxException e) {
+ throw new RuntimeException(e);
+ }
+
+ log.debugf("Sending redirect to the end_session_endpoint: %s", logoutUri);
+ facade.getResponse().setStatus(HttpStatus.SC_MOVED_TEMPORARILY);
+ facade.getResponse().setHeader(HttpConstants.LOCATION, logoutUri);
+ }
+
+ boolean tryBackChannelLogout(OidcHttpFacade facade) {
+ log.trace("tryBackChannelLogout entered");
+ if (isLogoutCallbackPath(facade)) {
+ log.trace("isLogoutCallbackPath");
+ if (isBackChannel(facade)) {
+ log.trace("isBackChannel");
+ handleBackChannelLogoutRequest(facade);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void handleBackChannelLogoutRequest(OidcHttpFacade facade) {
+ String logoutToken = facade.getRequest().getFirstParam(LOGOUT_TOKEN_PARAM);
+ TokenValidator tokenValidator = TokenValidator.builder(facade.getOidcClientConfiguration())
+ .setSkipExpirationValidator()
+ .setTokenType(LOGOUT_TOKEN_TYPE)
+ .build();
+ JwtClaims claims;
+
+ try {
+ claims = tokenValidator.verify(logoutToken);
+ } catch (Exception cause) {
+ log.debug("Unexpected error when verifying logout token", cause);
+ facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST);
+ facade.authenticationFailed();
+ return;
+ }
+
+ if (!isSessionRequiredOnLogout(facade)) {
+ log.warn("Back-channel logout request received but can not infer sid from logout token to mark it for invalidation");
+ facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST);
+ facade.authenticationFailed();
+ return;
+ }
+
+ String sessionId = claims.getClaimValueAsString(SID);
+
+ if (sessionId == null) {
+ facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST);
+ facade.authenticationFailed();
+ return;
+ }
+
+ log.debug("Marking session for invalidation during back-channel logout");
+ sessionsMarkedForInvalidation.put(getSessionKey(facade, sessionId), facade.getOidcClientConfiguration());
+ }
+
+ private String getSessionKey(OidcHttpFacade facade, String sessionId) {
+ return facade.getOidcClientConfiguration().getClientId() + CLIENT_ID_SID_SEPARATOR + sessionId;
+ }
+
+ private void handleFrontChannelLogoutRequest(OidcHttpFacade facade) {
+ if (isSessionRequiredOnLogout(facade)) {
+ Request request = facade.getRequest();
+ String sessionId = request.getQueryParamValue(SID);
+
+ if (sessionId == null) {
+ facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST);
+ facade.authenticationFailed();
+ return;
+ }
+
+ RefreshableOidcSecurityContext context = getSecurityContext(facade);
+ IDToken idToken = context.getIDToken();
+ String issuer = request.getQueryParamValue(ISS);
+
+ if (idToken == null || !sessionId.equals(idToken.getSid()) || !idToken.getIssuer().equals(issuer)) {
+ facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST);
+ facade.authenticationFailed();
+ return;
+ }
+ }
+
+ log.debug("Invalidating session during front-channel logout");
+ facade.getTokenStore().logout(false);
+ }
+
+ private String getRedirectUri(OidcHttpFacade facade) {
+ String uri = facade.getRequest().getURI();
+
+ if (uri.indexOf('?') != -1) {
+ uri = uri.substring(0, uri.indexOf('?'));
+ }
+ int logoutPathIndex = uri.indexOf(getLogoutPath(facade));
+
+ if (logoutPathIndex != -1) {
+ uri = uri.substring(0, logoutPathIndex);
+ }
+
+ return uri;
+ }
+
+ private boolean isLogoutCallbackPath(OidcHttpFacade facade) {
+ String path = facade.getRequest().getRelativePath();
+ return path.endsWith(getLogoutCallbackPath(facade));
+ }
+
+ private boolean isRpInitiatedLogoutPath(OidcHttpFacade facade) {
+ String path = facade.getRequest().getRelativePath();
+ return path.endsWith(getLogoutPath(facade));
+ }
+
+ private boolean isSessionRequiredOnLogout(OidcHttpFacade facade) {
+ return facade.getOidcClientConfiguration().isSessionRequiredOnLogout();
+ }
+
+ private RefreshableOidcSecurityContext getSecurityContext(OidcHttpFacade facade) {
+ RefreshableOidcSecurityContext securityContext = (RefreshableOidcSecurityContext) facade.getSecurityContext();
+
+ if (securityContext == null) {
+ facade.getResponse().setStatus(HttpStatus.SC_UNAUTHORIZED);
+ facade.authenticationFailed();
+ return null;
+ }
+
+ return securityContext;
+ }
+
+ private String getLogoutPath(OidcHttpFacade facade) {
+ return facade.getOidcClientConfiguration().getLogoutPath();
+ }
+ private String getLogoutCallbackPath(OidcHttpFacade facade) {
+ return facade.getOidcClientConfiguration().getLogoutCallbackPath();
+ }
+
+ private boolean isBackChannel(OidcHttpFacade facade) {
+ return "post".equalsIgnoreCase(facade.getRequest().getMethod());
+ }
+
+ private boolean isFrontChannel(OidcHttpFacade facade) {
+ return "get".equalsIgnoreCase(facade.getRequest().getMethod());
+ }
+}
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/Oidc.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/Oidc.java
index c6b38c9ef4..cbff772366 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/Oidc.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/Oidc.java
@@ -173,6 +173,10 @@ public class Oidc {
public static final String CONFIDENTIAL_PORT = "confidential-port";
public static final String ENABLE_BASIC_AUTH = "enable-basic-auth";
public static final String PROVIDER_URL = "provider-url";
+ public static final String LOGOUT_PATH = "logout-path";
+ public static final String LOGOUT_CALLBACK_PATH = "logout-callback-path";
+ public static final String POST_LOGOUT_PATH = "post-logout-path";
+ public static final String LOGOUT_SESSION_REQUIRED = "logout-session-required";
/**
* Bearer token pattern.
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcAuthenticationMechanism.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcAuthenticationMechanism.java
index b83fc58472..a33d6b261d 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcAuthenticationMechanism.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcAuthenticationMechanism.java
@@ -41,6 +41,8 @@
*/
final class OidcAuthenticationMechanism implements HttpServerAuthenticationMechanism {
+ private static LogoutHandler logoutHandler = new LogoutHandler();
+
private final Map properties;
private final CallbackHandler callbackHandler;
private final OidcClientContext oidcClientContext;
@@ -58,6 +60,7 @@ public String getMechanismName() {
@Override
public void evaluateRequest(HttpServerRequest request) throws HttpAuthenticationException {
+ log.debug("evaluateRequest uri: " + request.getRequestURI().toString());
OidcClientContext oidcClientContext = getOidcClientContext(request);
if (oidcClientContext == null) {
log.debugf("Ignoring request for path [%s] from mechanism [%s]. No client configuration context found.", request.getRequestURI(), getMechanismName());
@@ -73,6 +76,11 @@ public void evaluateRequest(HttpServerRequest request) throws HttpAuthentication
}
RequestAuthenticator authenticator = createRequestAuthenticator(httpFacade, oidcClientConfiguration);
+ if (logoutHandler.isSessionMarkedForInvalidation(httpFacade)) {
+ // session marked for invalidation, invalidate it
+ log.debug("Invalidating pending logout session");
+ httpFacade.getTokenStore().logout(false);
+ }
httpFacade.getTokenStore().checkCurrentToken();
if ((oidcClientConfiguration.getAuthServerBaseUrl() != null && keycloakPreActions(httpFacade, oidcClientConfiguration))
|| preflightCors(httpFacade, oidcClientConfiguration)) {
@@ -83,7 +91,8 @@ public void evaluateRequest(HttpServerRequest request) throws HttpAuthentication
AuthOutcome outcome = authenticator.authenticate();
if (AuthOutcome.AUTHENTICATED.equals(outcome)) {
- if (new AuthenticatedActionsHandler(oidcClientConfiguration, httpFacade).handledRequest()) {
+ if (new AuthenticatedActionsHandler(oidcClientConfiguration, httpFacade).handledRequest()
+ || logoutHandler.tryLogout(httpFacade)) {
httpFacade.authenticationInProgress();
} else {
httpFacade.authenticationComplete();
@@ -91,6 +100,13 @@ public void evaluateRequest(HttpServerRequest request) throws HttpAuthentication
return;
}
+ if (AuthOutcome.NOT_ATTEMPTED.equals(outcome)) {
+ if (logoutHandler.tryBackChannelLogout(httpFacade)) {
+ httpFacade.authenticationInProgress();
+ return;
+ }
+ }
+
AuthChallenge challenge = authenticator.getChallenge();
if (challenge != null) {
httpFacade.noAuthenticationInProgress(challenge);
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfiguration.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfiguration.java
index ca56da2863..0fc0334107 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfiguration.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfiguration.java
@@ -75,7 +75,7 @@ public enum RelativeUrlsUsed {
protected String providerUrl;
protected String authUrl;
protected String tokenUrl;
- protected String logoutUrl;
+ protected String endSessionEndpointUrl;
protected String accountUrl;
protected String registerNodeUrl;
protected String unregisterNodeUrl;
@@ -143,6 +143,14 @@ public enum RelativeUrlsUsed {
protected String requestObjectSigningKeyAlias;
protected String requestObjectSigningKeyStoreType;
protected JWKEncPublicKeyLocator encryptionPublicKeyLocator;
+ private boolean logoutSessionRequired = true;
+
+ private String postLogoutPath;
+ private boolean sessionRequiredOnLogout = true;
+ private String logoutPath = "/logout";
+ private String logoutCallbackPath = "/logout/callback";
+
+ private int logoutSessionWaitingLimit = 100;
public OidcClientConfiguration() {
}
@@ -202,7 +210,7 @@ public void setAuthServerBaseUrl(OidcJsonConfiguration config) {
protected void resetUrls() {
authUrl = null;
tokenUrl = null;
- logoutUrl = null;
+ endSessionEndpointUrl = null;
accountUrl = null;
registerNodeUrl = null;
unregisterNodeUrl = null;
@@ -238,7 +246,7 @@ protected void resolveUrls() {
authUrl = config.getAuthorizationEndpoint();
issuerUrl = config.getIssuer();
tokenUrl = config.getTokenEndpoint();
- logoutUrl = config.getLogoutEndpoint();
+ endSessionEndpointUrl = config.getLogoutEndpoint();
jwksUrl = config.getJwksUri();
requestParameterSupported = config.getRequestParameterSupported();
requestObjectSigningAlgValuesSupported = config.getRequestObjectSigningAlgValuesSupported();
@@ -323,9 +331,13 @@ public String getTokenUrl() {
return tokenUrl;
}
- public String getLogoutUrl() {
+ public String getEndSessionEndpointUrl() {
resolveUrls();
- return logoutUrl;
+ return endSessionEndpointUrl;
+ }
+
+ public String getLogoutPath() {
+ return logoutPath;
}
public String getAccountUrl() {
@@ -692,6 +704,22 @@ public String getTokenSignatureAlgorithm() {
return tokenSignatureAlgorithm;
}
+ public boolean isSessionRequiredOnLogout() {
+ return sessionRequiredOnLogout;
+ }
+
+ public void setSessionRequiredOnLogout(boolean sessionRequiredOnLogout) {
+ this.sessionRequiredOnLogout = sessionRequiredOnLogout;
+ }
+
+ public int getLogoutSessionWaitingLimit() {
+ return logoutSessionWaitingLimit;
+ }
+
+ public void setLogoutSessionWaitingLimit(int logoutSessionWaitingLimit) {
+ this.logoutSessionWaitingLimit = logoutSessionWaitingLimit;
+ }
+
public String getAuthenticationRequestFormat() {
return authenticationRequestFormat;
}
@@ -779,4 +807,32 @@ public void setEncryptionPublicKeyLocator(JWKEncPublicKeyLocator publicKeySetExt
public JWKEncPublicKeyLocator getEncryptionPublicKeyLocator() {
return this.encryptionPublicKeyLocator;
}
+
+ public void setPostLogoutPath(String postLogoutPath) {
+ this.postLogoutPath = postLogoutPath;
+ }
+
+ public String getPostLogoutPath() {
+ return postLogoutPath;
+ }
+
+ public boolean isLogoutSessionRequired() {
+ return logoutSessionRequired;
+ }
+
+ public void setLogoutSessionRequired(boolean logoutSessionRequired) {
+ this.logoutSessionRequired = logoutSessionRequired;
+ }
+
+ public void setLogoutPath(String logoutPath) {
+ this.logoutPath = logoutPath;
+ }
+
+ public String getLogoutCallbackPath() {
+ return logoutCallbackPath;
+ }
+
+ public void setLogoutCallbackPath(String logoutCallbackPath) {
+ this.logoutCallbackPath = logoutCallbackPath;
+ }
}
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfigurationBuilder.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfigurationBuilder.java
index 43bebace9f..51a2477386 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfigurationBuilder.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfigurationBuilder.java
@@ -25,6 +25,10 @@
import static org.wildfly.security.http.oidc.Oidc.AuthenticationRequestFormat.REQUEST_URI;
import static org.wildfly.security.http.oidc.Oidc.SSLRequired;
import static org.wildfly.security.http.oidc.Oidc.TokenStore;
+import static org.wildfly.security.http.oidc.Oidc.LOGOUT_PATH;
+import static org.wildfly.security.http.oidc.Oidc.LOGOUT_CALLBACK_PATH;
+import static org.wildfly.security.http.oidc.Oidc.POST_LOGOUT_PATH;
+import static org.wildfly.security.http.oidc.Oidc.LOGOUT_SESSION_REQUIRED;
import java.io.IOException;
import java.io.InputStream;
@@ -195,6 +199,49 @@ protected OidcClientConfiguration internalBuild(final OidcJsonConfiguration oidc
oidcClientConfiguration.setTokenSignatureAlgorithm(oidcJsonConfiguration.getTokenSignatureAlgorithm());
+ String tmpLogoutPath = System.getProperty(LOGOUT_PATH);
+ log.debug("sysProp LOGOUT_PATH: " + (tmpLogoutPath == null ? "NULL" : tmpLogoutPath));
+ if (tmpLogoutPath != null) {
+ if (isValidPath(tmpLogoutPath)) {
+ oidcClientConfiguration.setLogoutPath(tmpLogoutPath);
+ } else {
+ throw log.invalidLogoutPath(tmpLogoutPath, LOGOUT_PATH);
+ }
+ }
+
+
+ String tmpLogoutCallbackPath = System.getProperty(LOGOUT_CALLBACK_PATH);
+ log.debug("sysProp LOGOUT_CALLBACK_PATH: " + (tmpLogoutCallbackPath == null ? "NULL" : tmpLogoutCallbackPath));
+ if (tmpLogoutCallbackPath != null) {
+ if (isValidPath(tmpLogoutCallbackPath)
+ && !tmpLogoutCallbackPath.endsWith(oidcClientConfiguration.getLogoutPath())) {
+ oidcClientConfiguration.setLogoutCallbackPath(tmpLogoutCallbackPath);
+ } else {
+ if (!isValidPath(tmpLogoutCallbackPath)) {
+ throw log.invalidLogoutPath(tmpLogoutCallbackPath, LOGOUT_CALLBACK_PATH);
+ } else {
+ throw log.invalidLogoutCallbackPath(LOGOUT_CALLBACK_PATH, tmpLogoutCallbackPath,
+ LOGOUT_PATH, oidcClientConfiguration.getLogoutPath());
+ }
+ }
+ }
+
+ String tmpPostLogoutPath = System.getProperty(POST_LOGOUT_PATH);
+ log.debug("sysProp POST_LOGOUT_PATH: " + (tmpPostLogoutPath == null ? "NULL" : tmpPostLogoutPath));
+ if (tmpPostLogoutPath != null) {
+ if (isValidPath(tmpPostLogoutPath) || tmpPostLogoutPath.startsWith("http")) {
+ oidcClientConfiguration.setPostLogoutPath(tmpPostLogoutPath);
+ } else {
+ throw log.invalidLogoutPath(tmpPostLogoutPath, POST_LOGOUT_PATH);
+ }
+ }
+
+ String tmpLogoutSessionRequired = System.getProperty(LOGOUT_SESSION_REQUIRED);
+ if (tmpLogoutSessionRequired != null) {
+ oidcClientConfiguration.setLogoutSessionRequired(
+ Boolean.valueOf(tmpLogoutSessionRequired));
+ }
+
return oidcClientConfiguration;
}
@@ -236,4 +283,11 @@ public static OidcClientConfiguration build(OidcJsonConfiguration oidcJsonConfig
return new OidcClientConfigurationBuilder().internalBuild(oidcJsonConfiguration);
}
+ private boolean isValidPath(String path) {
+ String tmpPath = path.trim();
+ if (tmpPath.length() > 1 && tmpPath.startsWith("/")) {
+ return true;
+ }
+ return false;
+ }
}
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientContext.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientContext.java
index f5d930bd52..eed194681a 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientContext.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientContext.java
@@ -136,8 +136,8 @@ public String getTokenUrl() {
}
@Override
- public String getLogoutUrl() {
- return (this.logoutUrl != null) ? this.logoutUrl : delegate.getLogoutUrl();
+ public String getEndSessionEndpointUrl() {
+ return (this.endSessionEndpointUrl != null) ? this.endSessionEndpointUrl : delegate.getEndSessionEndpointUrl();
}
@Override
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcHttpFacade.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcHttpFacade.java
index 1c6f03fa7a..45def472a8 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcHttpFacade.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcHttpFacade.java
@@ -550,4 +550,21 @@ public String getPath() {
return path;
}
}
+
+ // rls debug only
+ public String rlsGetSessionIds() {
+ Collection sessions = request.getScopeIds(Scope.SESSION);
+ if (sessions == null) {
+ return "## OidcHttpFacade Scope.SESSION sessionIds is null.";
+ } else if (sessions.isEmpty()){
+ return "Scope.SESSION sessionIds is empty.";
+ }
+ StringBuffer sb = new StringBuffer();
+ sb.append("Scope.SESSION sessionIds: [");
+ for (String s : sessions) {
+ sb.append(s + ", ");
+ }
+ sb.append("]\n");
+ return sb.toString();
+ }
}
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcRequestAuthenticator.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcRequestAuthenticator.java
index 5ef5c26122..0588c16568 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcRequestAuthenticator.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcRequestAuthenticator.java
@@ -184,7 +184,6 @@ protected String getCode() {
protected String getRedirectUri(String state) {
String url = getRequestUrl();
log.debugf("callback uri: %s", url);
-
try {
if (! facade.getRequest().isSecure() && deployment.getSSLRequired().isRequired(facade.getRequest().getRemoteAddr())) {
int port = getSSLRedirectPort();
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcSessionTokenStore.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcSessionTokenStore.java
index cb6206177c..6762afc0f4 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcSessionTokenStore.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcSessionTokenStore.java
@@ -45,21 +45,29 @@ public OidcSessionTokenStore(OidcHttpFacade httpFacade) {
@Override
public void checkCurrentToken() {
HttpScope session = httpFacade.getScope(Scope.SESSION);
- if (session == null || ! session.exists()) return;
+ if (session == null || ! session.exists()) {
+ return;
+ }
RefreshableOidcSecurityContext securityContext = (RefreshableOidcSecurityContext) session.getAttachment(OidcSecurityContext.class.getName());
- if (securityContext == null) return;
+ if (securityContext == null) {
+ return;
+ }
// just in case session got serialized
if (securityContext.getOidcClientConfiguration() == null) {
securityContext.setCurrentRequestInfo(httpFacade.getOidcClientConfiguration(), this);
}
- if (securityContext.isActive() && ! securityContext.getOidcClientConfiguration().isAlwaysRefreshToken()) return;
+ if (securityContext.isActive() && ! securityContext.getOidcClientConfiguration().isAlwaysRefreshToken()) {
+ return;
+ }
// FYI: A refresh requires same scope, so same roles will be set. Otherwise, refresh will fail and token will
// not be updated
boolean success = securityContext.refreshToken(false);
- if (success && securityContext.isActive()) return;
+ if (success && securityContext.isActive()) {
+ return;
+ }
// Refresh failed, so user is already logged out from keycloak. Cleanup and expire our session
session.setAttachment(OidcSecurityContext.class.getName(), null);
@@ -132,7 +140,6 @@ public void saveAccountInfo(OidcAccount account) {
}
});
}
-
session.setAttachment(OidcAccount.class.getName(), account);
session.setAttachment(OidcSecurityContext.class.getName(), account.getOidcSecurityContext());
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/RequestAuthenticator.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/RequestAuthenticator.java
index 87b18e0abe..f635b7e79a 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/RequestAuthenticator.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/RequestAuthenticator.java
@@ -55,6 +55,7 @@ public RequestAuthenticator(OidcHttpFacade facade, OidcClientConfiguration deplo
public AuthOutcome authenticate() {
AuthOutcome authenticate = doAuthenticate();
if (AuthOutcome.AUTHENTICATED.equals(authenticate) && !facade.isAuthorized()) {
+ log.trace("## RequestAutenticator.authenticate AUTHENTICATED but NOT Autorized");
return AuthOutcome.FAILED;
}
return authenticate;
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/ServerRequest.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/ServerRequest.java
index 3a203541ee..8af9d75f32 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/ServerRequest.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/ServerRequest.java
@@ -102,7 +102,7 @@ public static AccessAndIDTokenResponse invokeRefresh(OidcClientConfiguration dep
public static void invokeLogout(OidcClientConfiguration deployment, String refreshToken) throws IOException, HttpFailure {
HttpClient client = deployment.getClient();
- String uri = deployment.getLogoutUrl();
+ String uri = deployment.getEndSessionEndpointUrl();
List formparams = new ArrayList<>();
formparams.add(new BasicNameValuePair(Oidc.REFRESH_TOKEN, refreshToken));
diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/TokenValidator.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/TokenValidator.java
index 746318043f..1c2a0e9108 100644
--- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/TokenValidator.java
+++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/TokenValidator.java
@@ -69,10 +69,12 @@ public Boolean run() {
private static final int HEADER_INDEX = 0;
private JwtConsumerBuilder jwtConsumerBuilder;
private OidcClientConfiguration clientConfiguration;
+ private String tokenType;
private TokenValidator(Builder builder) {
this.jwtConsumerBuilder = builder.jwtConsumerBuilder;
this.clientConfiguration = builder.clientConfiguration;
+ this.tokenType = builder.tokenType;
}
/**
@@ -110,11 +112,17 @@ public VerifiedTokens parseAndVerifyToken(final String idToken, final String acc
* @throws OidcException if the bearer token is invalid
*/
public AccessToken parseAndVerifyToken(final String bearerToken) throws OidcException {
+ return new AccessToken(verify(bearerToken));
+ }
+
+ public JwtClaims verify(String bearerToken) throws OidcException {
+ JwtClaims jwtClaims;
+
try {
JwtContext jwtContext = setVerificationKey(bearerToken, jwtConsumerBuilder);
jwtConsumerBuilder.setRequireSubject();
if (! DISABLE_TYP_CLAIM_VALIDATION_PROPERTY) {
- jwtConsumerBuilder.registerValidator(new TypeValidator("Bearer"));
+ jwtConsumerBuilder.registerValidator(new TypeValidator(tokenType));
}
if (clientConfiguration.isVerifyTokenAudience()) {
jwtConsumerBuilder.setExpectedAudience(clientConfiguration.getResourceName());
@@ -123,15 +131,15 @@ public AccessToken parseAndVerifyToken(final String bearerToken) throws OidcExce
}
// second pass to validate
jwtConsumerBuilder.build().processContext(jwtContext);
- JwtClaims jwtClaims = jwtContext.getJwtClaims();
+ jwtClaims = jwtContext.getJwtClaims();
if (jwtClaims == null) {
throw log.invalidBearerTokenClaims();
}
- return new AccessToken(jwtClaims);
} catch (InvalidJwtException e) {
log.tracef("Problem parsing bearer token: " + bearerToken, e);
throw log.invalidBearerToken(e);
}
+ return jwtClaims;
}
private JwtContext setVerificationKey(final String token, final JwtConsumerBuilder jwtConsumerBuilder) throws InvalidJwtException {
@@ -164,6 +172,8 @@ public static Builder builder(OidcClientConfiguration clientConfiguration) {
}
public static class Builder {
+
+ public String tokenType = "Bearer";
private OidcClientConfiguration clientConfiguration;
private String expectedIssuer;
private String clientId;
@@ -171,6 +181,7 @@ public static class Builder {
private PublicKeyLocator publicKeyLocator;
private SecretKey clientSecretKey;
private JwtConsumerBuilder jwtConsumerBuilder;
+ private boolean skipExpirationValidator;
/**
* Construct a new uninitialized instance.
@@ -213,11 +224,24 @@ public TokenValidator build() throws IllegalArgumentException {
jwtConsumerBuilder = new JwtConsumerBuilder()
.setExpectedIssuer(expectedIssuer)
.setJwsAlgorithmConstraints(
- new AlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, expectedJwsAlgorithm))
- .setRequireExpirationTime();
+ new AlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, expectedJwsAlgorithm));
+
+ if (!skipExpirationValidator) {
+ jwtConsumerBuilder.setRequireExpirationTime();
+ }
return new TokenValidator(this);
}
+
+ public Builder setSkipExpirationValidator() {
+ this.skipExpirationValidator = true;
+ return this;
+ }
+
+ public Builder setTokenType(String tokenType) {
+ this.tokenType = tokenType;
+ return this;
+ }
}
private static class AzpValidator implements ErrorCodeValidator {
diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/AbstractLogoutTest.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/AbstractLogoutTest.java
new file mode 100644
index 0000000000..85a270cfd8
--- /dev/null
+++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/AbstractLogoutTest.java
@@ -0,0 +1,235 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ * Copyright 2024 Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.wildfly.security.http.oidc;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assume.assumeTrue;
+import static org.wildfly.security.http.oidc.Oidc.OIDC_NAME;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+import io.restassured.RestAssured;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.wildfly.security.http.HttpAuthenticationException;
+import org.wildfly.security.http.HttpConstants;
+import org.wildfly.security.http.HttpScope;
+import org.wildfly.security.http.HttpServerAuthenticationMechanism;
+import org.wildfly.security.http.Scope;
+
+/**
+ * @author Pedro Igor
+ */
+public abstract class AbstractLogoutTest extends OidcBaseTest {
+
+ private ElytronDispatcher dispatcher;
+ private OidcClientConfiguration clientConfig;
+
+ @BeforeClass
+ public static void onBeforeClass() {
+ assumeTrue("Docker isn't available, OIDC tests will be skipped", isDockerAvailable());
+ KEYCLOAK_CONTAINER = new KeycloakContainer();
+ KEYCLOAK_CONTAINER.start();
+ System.setProperty("oidc.provider.url", KEYCLOAK_CONTAINER.getAuthServerUrl() + "/realms/" + TEST_REALM);
+ }
+
+ @AfterClass
+ public static void onAfterClass() {
+ System.clearProperty("oidc.provider.url");
+ }
+
+ @AfterClass
+ public static void generalCleanup() {
+ // no-op
+ }
+
+ @Before
+ public void onBefore() throws Exception {
+ OidcBaseTest.client = new MockWebServer();
+ OidcBaseTest.client.start(new InetSocketAddress(0).getAddress(), CLIENT_PORT);
+ configureDispatcher();
+ RealmRepresentation realm = KeycloakConfiguration.getRealmRepresentation(
+ TEST_REALM, CLIENT_ID, CLIENT_SECRET, CLIENT_HOST_NAME, CLIENT_PORT,
+ CLIENT_APP, false);
+
+ realm.setAccessTokenLifespan(100);
+ realm.setSsoSessionMaxLifespan(100);
+
+ ClientRepresentation client = realm.getClients().get(0);
+
+ client.setAttributes(new HashMap<>());
+
+ doConfigureClient(client);
+
+ List redirectUris = new ArrayList<>(client.getRedirectUris());
+
+ redirectUris.add("*");
+
+ client.setRedirectUris(redirectUris);
+
+ sendRealmCreationRequest(realm);
+ }
+
+ @After
+ public void onAfter() throws IOException {
+ client.shutdown();
+ RestAssured
+ .given()
+ .auth().oauth2(KeycloakConfiguration.getAdminAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl()))
+ .when()
+ .delete(KEYCLOAK_CONTAINER.getAuthServerUrl() + "/admin/realms/" + TEST_REALM).then().statusCode(204);
+ }
+
+ protected void doConfigureClient(ClientRepresentation client) {
+ }
+
+ protected OidcJsonConfiguration getClientConfiguration() {
+ OidcJsonConfiguration config = new OidcJsonConfiguration();
+
+ config.setRealm(TEST_REALM);
+ config.setResource(CLIENT_ID);
+ config.setPublicClient(false);
+ config.setAuthServerUrl(KEYCLOAK_CONTAINER.getAuthServerUrl());
+ config.setSslRequired("EXTERNAL");
+ config.setCredentials(new HashMap<>());
+ config.getCredentials().put("secret", CLIENT_SECRET);
+
+ return config;
+ }
+
+ protected TestingHttpServerRequest getCurrentRequest() {
+ return dispatcher.getCurrentRequest();
+ }
+
+ protected HttpScope getCurrentSession() {
+ return getCurrentRequest().getScope(Scope.SESSION);
+ }
+
+ protected OidcClientConfiguration getClientConfig() {
+ return clientConfig;
+ }
+
+ protected TestingHttpServerResponse getCurrentResponse() {
+ try {
+ return dispatcher.getCurrentRequest().getResponse();
+ } catch (HttpAuthenticationException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ class ElytronDispatcher extends Dispatcher {
+
+ volatile TestingHttpServerRequest currentRequest;
+
+ private final HttpServerAuthenticationMechanism mechanism;
+ private Dispatcher beforeDispatcher;
+ private HttpScope sessionScope;
+
+ public ElytronDispatcher(HttpServerAuthenticationMechanism mechanism, Dispatcher beforeDispatcher) {
+ this.mechanism = mechanism;
+ this.beforeDispatcher = beforeDispatcher;
+ }
+
+ @Override
+ public MockResponse dispatch(RecordedRequest serverRequest) throws InterruptedException {
+ if (beforeDispatcher != null) {
+ MockResponse response = beforeDispatcher.dispatch(serverRequest);
+ if (response != null) {
+ return response;
+ }
+ }
+
+ MockResponse mockResponse = new MockResponse();
+
+ try {
+ currentRequest = new TestingHttpServerRequest(serverRequest, sessionScope);
+
+ mechanism.evaluateRequest(currentRequest);
+
+ TestingHttpServerResponse response = currentRequest.getResponse();
+
+ if (Status.COMPLETE.equals(currentRequest.getResult())) {
+ mockResponse.setBody("Welcome, authenticated user");
+ sessionScope = currentRequest.getScope(Scope.SESSION);
+ } else {
+ boolean statusSet = response.getStatusCode() > 0;
+
+ if (statusSet) {
+ mockResponse.setResponseCode(response.getStatusCode());
+
+ if (response.getLocation() != null) {
+ mockResponse.setHeader(HttpConstants.LOCATION, response.getLocation());
+ }
+ } else {
+ mockResponse.setResponseCode(201);
+ mockResponse.setBody("from " + serverRequest.getPath());
+ }
+ }
+ } catch (Exception cause) {
+ cause.printStackTrace();
+ mockResponse.setResponseCode(500);
+ }
+
+ return mockResponse;
+ }
+
+ public TestingHttpServerRequest getCurrentRequest() {
+ return currentRequest;
+ }
+ }
+
+ protected void configureDispatcher() {
+ configureDispatcher(OidcClientConfigurationBuilder.build(getClientConfiguration()), null);
+ }
+
+ protected void configureDispatcher(OidcClientConfiguration clientConfig, Dispatcher beforeDispatch) {
+ this.clientConfig = clientConfig;
+ OidcClientContext oidcClientContext = new OidcClientContext(clientConfig);
+ oidcFactory = new OidcMechanismFactory(oidcClientContext);
+ HttpServerAuthenticationMechanism mechanism;
+ try {
+ mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, Collections.emptyMap(), getCallbackHandler());
+ } catch (HttpAuthenticationException e) {
+ throw new RuntimeException(e);
+ }
+ dispatcher = new ElytronDispatcher(mechanism, beforeDispatch);
+ client.setDispatcher(dispatcher);
+ }
+
+ protected void assertUserNotAuthenticated() {
+ assertNull(getCurrentSession());
+ }
+
+ protected void assertUserAuthenticated() {
+ assertNotNull(getCurrentSession().getAttachment(OidcAccount.class.getName()));
+ }
+}
diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/BackChannelLogoutTest.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/BackChannelLogoutTest.java
new file mode 100644
index 0000000000..d39e5af418
--- /dev/null
+++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/BackChannelLogoutTest.java
@@ -0,0 +1,79 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ * Copyright 2024 Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.wildfly.security.http.oidc;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.net.InetAddress;
+import java.net.URI;
+import java.net.UnknownHostException;
+import java.util.List;
+
+import com.gargoylesoftware.htmlunit.Page;
+import com.gargoylesoftware.htmlunit.WebClient;
+import org.apache.http.HttpStatus;
+import org.junit.Test;
+import org.keycloak.representations.idm.ClientRepresentation;
+
+public class BackChannelLogoutTest extends AbstractLogoutTest {
+
+ @Override
+ protected void doConfigureClient(ClientRepresentation client) {
+ List redirectUris = client.getRedirectUris();
+ String redirectUri = redirectUris.get(0);
+
+ OidcClientConfiguration config = new OidcClientConfiguration();
+ client.setFrontchannelLogout(false);
+ client.getAttributes().put("backchannel.logout.session.required", "true");
+ client.getAttributes().put("backchannel.logout.url", rewriteHost(redirectUri)
+ + config.getLogoutCallbackPath());
+ }
+
+ private static String rewriteHost(String redirectUri) {
+ try {
+ return redirectUri.replace("localhost", InetAddress.getLocalHost().getHostAddress());
+ } catch (UnknownHostException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ public void testBackChannelLogout() throws Exception {
+ URI requestUri = new URI(getClientUrl());
+ WebClient webClient = getWebClient();
+
+ webClient.getPage(getClientUrl());
+ TestingHttpServerResponse response = getCurrentResponse();
+ assertEquals(HttpStatus.SC_MOVED_TEMPORARILY, response.getStatusCode());
+ assertEquals(Status.NO_AUTH, getCurrentRequest().getResult());
+
+ webClient = getWebClient();
+ Page page = loginToKeycloak(webClient, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD,
+ requestUri, response.getLocation(),
+ response.getCookies())
+ .click();
+ assertTrue(page.getWebResponse().getContentAsString().contains("Welcome, authenticated user"));
+
+ // logged out after finishing the redirections during frontchannel logout
+ assertUserAuthenticated();
+ webClient.getPage(getClientUrl() + getClientConfig().getLogoutPath());
+ assertUserNotAuthenticated();
+ }
+}
diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/FrontChannelLogoutTest.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/FrontChannelLogoutTest.java
new file mode 100644
index 0000000000..3a20cb1f50
--- /dev/null
+++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/FrontChannelLogoutTest.java
@@ -0,0 +1,128 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ * Copyright 2024 Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.wildfly.security.http.oidc;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.net.URI;
+import java.util.List;
+
+import com.gargoylesoftware.htmlunit.Page;
+import com.gargoylesoftware.htmlunit.TextPage;
+import com.gargoylesoftware.htmlunit.WebClient;
+import com.gargoylesoftware.htmlunit.html.HtmlForm;
+import com.gargoylesoftware.htmlunit.html.HtmlPage;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.QueueDispatcher;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.apache.http.HttpStatus;
+import org.junit.Test;
+import org.keycloak.representations.idm.ClientRepresentation;
+
+/**
+ * Tests for the OpenID Connect authentication mechanism.
+ *
+ * @author Farah Juma
+ */
+public class FrontChannelLogoutTest extends AbstractLogoutTest {
+
+ @Override
+ protected void doConfigureClient(ClientRepresentation client) {
+ client.setFrontchannelLogout(true);
+ List redirectUris = client.getRedirectUris();
+ String redirectUri = redirectUris.get(0);
+ OidcClientConfiguration config = new OidcClientConfiguration();
+ client.getAttributes().put("frontchannel.logout.url", redirectUri
+ + config.getLogoutCallbackPath());
+ }
+
+ @Test
+ public void testRPInitiatedLogout() throws Exception {
+ URI requestUri = new URI(getClientUrl());
+ WebClient webClient = getWebClient();
+ webClient.getPage(getClientUrl());
+ TestingHttpServerResponse response = getCurrentResponse();
+ assertEquals(HttpStatus.SC_MOVED_TEMPORARILY, response.getStatusCode());
+ assertEquals(Status.NO_AUTH, getCurrentRequest().getResult());
+
+ webClient = getWebClient();
+ Page page = loginToKeycloak(webClient, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD,
+ requestUri, response.getLocation(),
+ response.getCookies())
+ .click();
+ assertTrue(page.getWebResponse().getContentAsString().contains("Welcome, authenticated user"));
+
+ // logged out after finishing the redirections during frontchannel logout
+ assertUserAuthenticated();
+ webClient.getPage(getClientUrl() + getClientConfig().getLogoutPath());
+ assertUserNotAuthenticated();
+ }
+
+ @Test
+ public void testRPInitiatedLogoutWithPostLogoutUri() throws Exception {
+ OidcClientConfiguration oidcClientConfiguration = getClientConfig();
+ oidcClientConfiguration.setPostLogoutPath(getClientUrl()+"/post-logout");
+ configureDispatcher(oidcClientConfiguration, new Dispatcher() {
+ @Override
+ public MockResponse dispatch(RecordedRequest request) {
+ if (request.getPath().contains("/post-logout")) {
+ return new MockResponse()
+ .setBody("you are logged out from app");
+ }
+ return null;
+ }
+ });
+
+ URI requestUri = new URI(getClientUrl());
+ WebClient webClient = getWebClient();
+ webClient.getPage(getClientUrl());
+ TestingHttpServerResponse response = getCurrentResponse();
+ Page page = loginToKeycloak(webClient, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, requestUri, response.getLocation(),
+ response.getCookies()).click();
+ assertTrue(page.getWebResponse().getContentAsString().contains("Welcome, authenticated user"));
+
+ assertUserAuthenticated();
+ HtmlPage continueLogout = webClient.getPage(getClientUrl() + getClientConfig().getLogoutPath());
+ page = continueLogout.getElementById("continue").click();
+ assertUserNotAuthenticated();
+ assertTrue(page.getWebResponse().getContentAsString().contains("you are logged out from app"));
+ }
+
+ @Test
+ public void testFrontChannelLogout() throws Exception {
+ try {
+ URI requestUri = new URI(getClientUrl());
+ WebClient webClient = getWebClient();
+ webClient.getPage(getClientUrl());
+ TextPage page = loginToKeycloak(webClient, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, requestUri, getCurrentResponse().getLocation(),
+ getCurrentResponse().getCookies()).click();
+ assertTrue(page.getContent().contains("Welcome, authenticated user"));
+
+ HtmlPage logoutPage = webClient.getPage(getClientConfig().getEndSessionEndpointUrl() + "?client_id=" + CLIENT_ID);
+ HtmlForm form = logoutPage.getForms().get(0);
+ assertUserAuthenticated();
+ form.getInputByName("confirmLogout").click();
+ assertUserNotAuthenticated();
+ } finally {
+ client.setDispatcher(new QueueDispatcher());
+ }
+ }
+}
diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/LogoutConfigurationOptionsTest.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/LogoutConfigurationOptionsTest.java
new file mode 100644
index 0000000000..c6fb24cf75
--- /dev/null
+++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/LogoutConfigurationOptionsTest.java
@@ -0,0 +1,118 @@
+/*
+ * JBoss, Home of Professional Open Source.
+ * Copyright 2024 Red Hat, Inc., and individual contributors
+ * as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.wildfly.security.http.oidc;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/*
+ Verify that invalid logout config options values are flagged.
+ */
+public class LogoutConfigurationOptionsTest {
+ private OidcJsonConfiguration oidcJsonConfiguration;
+
+ @Before
+ public void before() {
+ oidcJsonConfiguration = new OidcJsonConfiguration();
+ // minimum required options
+ oidcJsonConfiguration.setRealm("realm");
+ oidcJsonConfiguration.setResource("resource");
+ oidcJsonConfiguration.setClientId("clientId");
+ oidcJsonConfiguration.setAuthServerUrl("AuthServerUrl");
+ }
+
+ @After
+ public void after() {
+ System.clearProperty(Oidc.LOGOUT_PATH);
+ System.clearProperty(Oidc.LOGOUT_CALLBACK_PATH);
+ System.clearProperty(Oidc.POST_LOGOUT_PATH);
+ }
+
+ @Test
+ public void testLogoutPath() {
+
+ try {
+ System.setProperty(Oidc.LOGOUT_PATH, " ");
+ OidcClientConfigurationBuilder.build(oidcJsonConfiguration);
+ fail("Empty " +Oidc.LOGOUT_PATH+ " is invalid");
+ } catch (Exception e) {
+ assertTrue(e.getMessage().endsWith(Oidc.LOGOUT_PATH));
+ }
+
+ try {
+ System.setProperty(Oidc.LOGOUT_PATH, "/");
+ OidcClientConfigurationBuilder.build(oidcJsonConfiguration);
+ fail("/ in " +Oidc.LOGOUT_PATH+ " is invalid");
+ } catch (Exception e) {
+ assertTrue(e.getMessage().endsWith(Oidc.LOGOUT_PATH));
+ }
+ }
+
+ @Test
+ public void testCallbackLogoutPath() {
+
+ try {
+ System.setProperty(Oidc.LOGOUT_CALLBACK_PATH, " ");
+ OidcClientConfigurationBuilder.build(oidcJsonConfiguration);
+ fail("Empty " + Oidc.LOGOUT_CALLBACK_PATH + " is invalid");
+ } catch (Exception e) {
+ assertTrue(e.getMessage().endsWith(Oidc.LOGOUT_CALLBACK_PATH));
+ }
+
+ try {
+ System.setProperty(Oidc.LOGOUT_CALLBACK_PATH, "/");
+ OidcClientConfigurationBuilder.build(oidcJsonConfiguration);
+ fail("/ in " + Oidc.LOGOUT_CALLBACK_PATH + " is invalid");
+ } catch (Exception e) {
+ assertTrue(e.getMessage().endsWith(Oidc.LOGOUT_CALLBACK_PATH));
+ }
+
+ try {
+ System.setProperty(Oidc.LOGOUT_PATH, "/mylogout");
+ System.setProperty(Oidc.LOGOUT_CALLBACK_PATH, "/more/mylogout");
+ OidcClientConfigurationBuilder.build(oidcJsonConfiguration);
+ fail("Identical paths is invalid");
+ } catch (Exception e) {
+ assertTrue(e.getMessage().contains("ELY23072"));
+ }
+ }
+
+ @Test
+ public void testPostLogoutPath() {
+
+ try {
+ System.setProperty(Oidc.POST_LOGOUT_PATH, " ");
+ OidcClientConfigurationBuilder.build(oidcJsonConfiguration);
+ fail("Empty " +Oidc.POST_LOGOUT_PATH+ " is invalid");
+ } catch (Exception e) {
+ assertTrue(e.getMessage().endsWith(Oidc.POST_LOGOUT_PATH));
+ }
+
+ try {
+ System.setProperty(Oidc.POST_LOGOUT_PATH, "/");
+ OidcClientConfigurationBuilder.build(oidcJsonConfiguration);
+ fail("/ in " + Oidc.POST_LOGOUT_PATH + " is invalid");
+ } catch (Exception e) {
+ assertTrue(e.getMessage().endsWith(Oidc.POST_LOGOUT_PATH));
+ }
+ }
+}
diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcBaseTest.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcBaseTest.java
index 6eb698160a..4f1028f829 100644
--- a/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcBaseTest.java
+++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcBaseTest.java
@@ -1,6 +1,6 @@
/*
* JBoss, Home of Professional Open Source.
- * Copyright 2022 Red Hat, Inc., and individual contributors
+ * Copyright 2024 Red Hat, Inc., and individual contributors
* as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -279,6 +279,7 @@ static WebClient getWebClient() {
WebClient webClient = new WebClient();
webClient.setCssErrorHandler(new SilentCssErrorHandler());
webClient.setJavaScriptErrorListener(new SilentJavaScriptErrorListener());
+ webClient.getOptions().setMaxInMemory(50000 * 1024);
return webClient;
}
@@ -291,7 +292,10 @@ protected static String getClientUrlForTenant(String tenant) {
}
protected HtmlInput loginToKeycloak(String username, String password, URI requestUri, String location, List cookies) throws IOException {
- WebClient webClient = getWebClient();
+ return loginToKeycloak(getWebClient(), username, password, requestUri, location, cookies);
+ }
+
+ protected HtmlInput loginToKeycloak(WebClient webClient, String username, String password, URI requestUri, String location, List cookies) throws IOException {
if (cookies != null) {
for (HttpServerCookie cookie : cookies) {
webClient.addCookie(getCookieString(cookie), requestUri.toURL(), null);
diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcTest.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcTest.java
index 4dede8b5ed..1d5a451005 100644
--- a/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcTest.java
+++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcTest.java
@@ -584,7 +584,7 @@ private void performTenantRequestWithProviderUrl(String username, String passwor
private void performTenantRequest(String username, String password, String tenant, String otherTenant, boolean useAuthServerUrl) throws Exception {
try {
Map props = new HashMap<>();
- Map sessionScopeAttachments = new HashMap<>();
+ Map attachments = new HashMap<>();
String clientPageText = getClientPageTestForTenant(tenant);
String expectedLocation = getClientUrlForTenant(tenant);
@@ -604,14 +604,14 @@ private void performTenantRequest(String username, String password, String tenan
assertEquals(Status.NO_AUTH, request.getResult());
// log into Keycloak, we should then be redirected back to the tenant upon successful authentication
- client.setDispatcher(createAppResponse(mechanism, HttpStatus.SC_MOVED_TEMPORARILY, expectedLocation, clientPageText, sessionScopeAttachments));
+ client.setDispatcher(createAppResponse(mechanism, HttpStatus.SC_MOVED_TEMPORARILY, expectedLocation, clientPageText, attachments));
TextPage page = loginToKeycloak(username, password, requestUri, response.getLocation(),
response.getCookies()).click();
assertTrue(page.getContent().contains(clientPageText));
if (otherTenant != null) {
// attempt to access the other tenant
- client.setDispatcher(createAppResponse(mechanism, clientPageText, sessionScopeAttachments, otherTenant, tenant.equals(otherTenant)));
+ client.setDispatcher(createAppResponse(mechanism, clientPageText, attachments, otherTenant, tenant.equals(otherTenant)));
WebClient webClient = getWebClient();
page = webClient.getPage(getClientUrlForTenant(otherTenant));
if (otherTenant.equals(tenant)) {
diff --git a/tests/base/src/test/java/org/wildfly/security/http/impl/AbstractBaseHttpTest.java b/tests/base/src/test/java/org/wildfly/security/http/impl/AbstractBaseHttpTest.java
index 7b8308fd8c..0938a6297e 100644
--- a/tests/base/src/test/java/org/wildfly/security/http/impl/AbstractBaseHttpTest.java
+++ b/tests/base/src/test/java/org/wildfly/security/http/impl/AbstractBaseHttpTest.java
@@ -1,6 +1,6 @@
/*
* JBoss, Home of Professional Open Source.
- * Copyright 2017 Red Hat, Inc., and individual contributors
+ * Copyright 2024 Red Hat, Inc., and individual contributors
* as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -51,6 +51,8 @@
import javax.security.auth.x500.X500Principal;
import javax.security.sasl.AuthorizeCallback;
import javax.security.sasl.RealmCallback;
+
+import okhttp3.mockwebserver.RecordedRequest;
import org.hamcrest.CoreMatchers;
import org.hamcrest.MatcherAssert;
import org.junit.Assert;
@@ -144,6 +146,8 @@ protected enum Status {
protected static class TestingHttpServerRequest implements HttpServerRequest {
+ private String contentType;
+ private String body;
private Status result;
private HttpServerMechanismsResponder responder;
private String remoteUser;
@@ -153,6 +157,7 @@ protected static class TestingHttpServerRequest implements HttpServerRequest {
private Map> requestHeaders = new HashMap<>();
private X500Principal testPrincipal = null;
private Map sessionScopeAttachments = new HashMap<>();
+ private HttpScope sessionScope;
public TestingHttpServerRequest(String[] authorization) {
if (authorization != null) {
@@ -221,6 +226,14 @@ public TestingHttpServerRequest(String[] authorization, URI requestURI, String c
}
}
+ public TestingHttpServerRequest(RecordedRequest request, HttpScope sessionScope) {
+ this(new String[0], request.getRequestUrl().uri(), request.getHeader("Cookie"));
+ this.requestMethod = request.getMethod();
+ this.body = request.getBody().readUtf8();
+ this.contentType = request.getHeader("Content-Type");
+ this.sessionScope = sessionScope;
+ }
+
public Status getResult() {
return result;
}
@@ -292,7 +305,7 @@ public URI getRequestURI() {
}
public String getRequestPath() {
- throw new IllegalStateException();
+ return requestURI.getPath();
}
public Map> getParameters() {
@@ -308,6 +321,19 @@ public List getParameterValues(String name) {
}
public String getFirstParameterValue(String name) {
+ if ("application/x-www-form-urlencoded".equals(contentType)) {
+ if (body == null) {
+ return null;
+ }
+
+ for (String keyValue : body.split("&")) {
+ String key = keyValue.substring(0, keyValue.indexOf('='));
+
+ if (key.equals(name)) {
+ return keyValue.substring(keyValue.indexOf('=') + 1);
+ }
+ }
+ }
throw new IllegalStateException();
}
@@ -332,48 +358,53 @@ public boolean resumeRequest() {
}
public HttpScope getScope(Scope scope) {
+ if (requestURI != null && "/clientApp/logout/callback".equals(requestURI.getPath())){
+ return null;
+ }
if (scope.equals(Scope.SSL_SESSION)) {
return null;
- } else {
- return new HttpScope() {
+ } else if (sessionScope != null) {
+ return sessionScope;
+ }
- @Override
- public boolean exists() {
- return true;
- }
+ return new HttpScope() {
- @Override
- public boolean create() {
- return false;
- }
+ @Override
+ public boolean exists() {
+ return true;
+ }
- @Override
- public boolean supportsAttachments() {
- return true;
- }
+ @Override
+ public boolean create() {
+ return false;
+ }
- @Override
- public boolean supportsInvalidation() {
- return false;
- }
+ @Override
+ public boolean supportsAttachments() {
+ return true;
+ }
- @Override
- public void setAttachment(String key, Object value) {
- if (scope.equals(Scope.SESSION)) {
- sessionScopeAttachments.put(key, value);
- }
+ @Override
+ public boolean supportsInvalidation() {
+ return false;
+ }
+
+ @Override
+ public void setAttachment(String key, Object value) {
+ if (scope.equals(Scope.SESSION)) {
+ sessionScopeAttachments.put(key, value);
}
+ }
- @Override
- public Object getAttachment(String key) {
- if (scope.equals(Scope.SESSION)) {
- return sessionScopeAttachments.get(key);
- } else {
- return null;
- }
+ @Override
+ public Object getAttachment(String key) {
+ if (scope.equals(Scope.SESSION)) {
+ return sessionScopeAttachments.get(key);
+ } else {
+ return null;
}
- };
- }
+ }
+ };
}
public Collection getScopeIds(Scope scope) {
diff --git a/tool/LICENSE.txt b/tool/LICENSE.txt
deleted file mode 120000
index 57ddfc0a05..0000000000
--- a/tool/LICENSE.txt
+++ /dev/null
@@ -1 +0,0 @@
-src/main/resources/META-INF/LICENSE.txt
\ No newline at end of file
diff --git a/tool/LICENSE.txt b/tool/LICENSE.txt
new file mode 100644
index 0000000000..d645695673
--- /dev/null
+++ b/tool/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.