You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by ns...@apache.org on 2026/03/31 13:05:46 UTC
(sling-org-apache-sling-auth-oauth-client) branch master updated: SLING-13119 Enhance OIDC authentication with support for SP-initiated logout
This is an automated email from the ASF dual-hosted git repository.
nscendoni pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-auth-oauth-client.git
The following commit(s) were added to refs/heads/master by this push:
new 9f068e9 SLING-13119 Enhance OIDC authentication with support for SP-initiated logout
9f068e9 is described below
commit 9f068e918dc2caadb94842c272d0aa66d8d78549
Author: Nicola Scendoni <sc...@gmail.com>
AuthorDate: Tue Mar 31 15:05:40 2026 +0200
SLING-13119 Enhance OIDC authentication with support for SP-initiated logout
* SLING-13049 Enhance OIDC authentication with support for SP-initiated logout
- Added support for SP-initiated single logout by introducing an end_session_endpoint configuration in OidcConnectionImpl.
- Updated OidcAuthenticationHandler to handle logout redirection to the IdP's end session endpoint.
- Implemented logic to store the ID token for use during logout.
- Enhanced tests to verify the correct behavior of the logout process, including redirection and allowed hosts validation.
- Refactored related classes to accommodate new logout functionality.
- possibility to delete the user tokens on logout
- Flag to enable idp single logout
- redirect parameter to logout
* Refactoring Tests
Implementation of review findings
* OidcLogoutHandler.java
- S2589: Removed redundant scheme == null / host == null checks (they're @NotNull) — kept empty-only validation
- S1118: Added private UriBuilder() {} constructor
- S107: Added @SuppressWarnings("java:S107") on the 8-param constructor
- S1075: Extracted ROOT_PATH = "/" constant, replaced hard-coded "/" usages
JcrUserHomeOAuthTokenStore.java
- S3776: Extracted readEncryptedProperty() and decryptIdToken() helpers, reducing cognitive complexity of getIdToken()
- S5145: Added safeUserId sanitization (replace('\n','_').replace('\r','_')) used in all log calls
- S1141: Inner try/catch moved into decryptIdToken() helper
- S2139: Removed logger.error from the outer catch; now throws OAuthException(String, Throwable) with context message (added the two-arg constructor to OAuthException)
OidcAuthenticationHandlerTest.java
- S4144: Deleted requestCredentialsWithNullResourceAttribute (identical to requestCredentialsWithEmptyResourceAttribute)
- S5976: Replaced 6 similar dropCredentials tests with a single @ParameterizedTest @MethodSource("redirectPathScenarios") covering all 6 cases
* Added tests to meet SonarQube standards
JcrUserHomeOAuthTokenStoreTest — 9 tests for the new getIdToken() family:
- Session not JackrabbitSession → null
- User not found / user is group → null
- Token found at each of the 3 search paths (oauth-tokens/{conn}/id_token, profile/id_token, id_token)
- No token at any path → null
- Empty values array / empty token string → null (covers readEncryptedProperty edge cases)
- Decryption fails → null (covers decryptIdToken error path)
- RepositoryException → throws OAuthException (covers new OAuthException(String, Throwable) constructor)
OidcLogoutHandlerTest — 13 tests covering missing branches:
- resolveConnectionForLogout: empty connections, default name not found in map, empty default name
- getEndSessionEndpoint: non-OidcConnectionImpl connection, OidcConnectionImpl with endpoint
- UriBuilder.buildRedirectUri: empty scheme throws, empty host throws, null path defaults to /, default port omitted
- getIdTokenFromOak with tokenStore: returns token from store, returns null when no connection available
* Addressed last comments from Robert and SonarQube
* - Modified OidcLogoutHandler resolveConnectionForLogout to return null if the connection doesn't exists
- Made OidcLogoutHandler Osgi Service
* Set configurationPolicy = OPTIONAL for JcrUserHomeOAuthTokenStore
* Removed service user and used ResourceResolver
* - Renamed getIdTokenFromOak to getIdTokenFromTokenStore
- Refactoring Tests
- Implemented getIdTokenFromTokenStore in RedisOAuthTokenStore and InMemoryOAuthTokenStore
- addressed Sonatype linter findings
- fix formatting
- added ConfigurationPolicy.REQUIRE to JcrUserHomeOAuthTokenStore
- Restored JcrUserHomeOAuthTokenStore(@Reference CryptoService cryptoService) as in master
- Added configuration for JcrUserHomeOAuthTokenStore in IT Tests
---
.../sling/auth/oauth_client/impl/Converter.java | 31 +-
.../impl/JcrUserHomeOAuthTokenStore.java | 50 ++
.../auth/oauth_client/impl/OAuthException.java | 4 +
.../auth/oauth_client/impl/OAuthTokenStore.java | 15 +
.../sling/auth/oauth_client/impl/OAuthTokens.java | 12 +
.../impl/OidcAuthenticationHandler.java | 108 ++++-
.../auth/oauth_client/impl/OidcConnectionImpl.java | 31 ++
.../auth/oauth_client/impl/OidcLogoutHandler.java | 315 ++++++++++++
.../impl/OidcProviderMetadataRegistry.java | 12 +
.../oauth_client/impl/RedisOAuthTokenStore.java | 11 +
.../oauth_client/impl/ResolvedOidcConnection.java | 17 +-
.../oauth_client/impl/SlingLoginCookieManager.java | 5 +
.../impl/SlingUserInfoProcessorImpl.java | 47 +-
.../auth/oauth_client/spi/LoginCookieManager.java | 9 +
.../auth/oauth_client/spi/UserInfoProcessor.java | 5 -
.../auth/oauth_client/AuthorizationCodeFlowIT.java | 76 ++-
.../auth/oauth_client/InMemoryOAuthTokenStore.java | 9 +
.../impl/InMemoryOAuthTokenStoreTest.java | 29 +-
.../impl/JcrUserHomeOAuthTokenStoreTest.java | 86 ++++
.../auth/oauth_client/impl/MockOidcConnection.java | 24 +-
.../impl/OidcAuthenticationHandlerTest.java | 528 ++++++++++++++++-----
.../oauth_client/impl/OidcLogoutHandlerTest.java | 153 ++++++
.../impl/RedisOAuthTokenStoreTest.java | 29 +-
.../impl/SlingLoginCookieManagerTest.java | 1 +
.../impl/SlingUserInfoProcessorImplTest.java | 184 +++++++
25 files changed, 1631 insertions(+), 160 deletions(-)
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/Converter.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/Converter.java
index 800052b..684442b 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/Converter.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/Converter.java
@@ -18,11 +18,14 @@
*/
package org.apache.sling.auth.oauth_client.impl;
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTParser;
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
import com.nimbusds.oauth2.sdk.token.RefreshToken;
import com.nimbusds.oauth2.sdk.token.Tokens;
import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
public class Converter {
@@ -65,8 +68,9 @@ public class Converter {
String refreshToken = nimbusTokens.getRefreshToken() != null
? nimbusTokens.getRefreshToken().getValue()
: null;
+ String idToken = nimbusTokens.getIDTokenString();
- return new OAuthTokens(accessToken, expiresAt, refreshToken);
+ return new OAuthTokens(accessToken, expiresAt, refreshToken, idToken);
}
public static @NotNull OAuthTokens toSlingOAuthTokens(@NotNull Tokens oAuthTokens) {
@@ -79,8 +83,31 @@ public class Converter {
String refreshToken = oAuthTokens.getRefreshToken() != null
? oAuthTokens.getRefreshToken().getValue()
: null;
+ String idToken = (oAuthTokens instanceof OIDCTokens) ? ((OIDCTokens) oAuthTokens).getIDTokenString() : null;
+
+ return new OAuthTokens(accessToken, expiresAt, refreshToken, idToken);
+ }
- return new OAuthTokens(accessToken, expiresAt, refreshToken);
+ /**
+ * Extracts a claim from an ID token string. Returns null if the ID token is null,
+ * cannot be parsed, or the claim is not present.
+ *
+ * @param idToken the serialized ID token (JWT string)
+ * @param claimName the name of the claim to extract
+ * @return the claim value, or null if not available
+ */
+ @Nullable
+ public static Object extractIdTokenClaim(@Nullable String idToken, @NotNull String claimName) {
+ if (idToken == null || idToken.isEmpty()) {
+ return null;
+ }
+
+ try {
+ JWT jwt = JWTParser.parse(idToken);
+ return jwt.getJWTClaimsSet().getClaim(claimName);
+ } catch (java.text.ParseException e) {
+ return null;
+ }
}
private Converter() {}
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/JcrUserHomeOAuthTokenStore.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/JcrUserHomeOAuthTokenStore.java
index d51df3f..26d24c3 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/JcrUserHomeOAuthTokenStore.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/JcrUserHomeOAuthTokenStore.java
@@ -152,6 +152,56 @@ public class JcrUserHomeOAuthTokenStore implements OAuthTokenStore {
}
}
+ @Override
+ public @Nullable String getIdToken(@NotNull ClientConnection connection, @NotNull ResourceResolver resolver)
+ throws OAuthException {
+ try {
+ User user = adaptToUser(resolver);
+
+ // Try multiple property paths: oauth-tokens/{connection}/id_token (stored by persistTokens),
+ // then profile/id_token (common when sync stores on profile), then id_token (bare name)
+ for (String relPath : new String[] {
+ propertyPath(connection, PROPERTY_NAME_ID_TOKEN),
+ PROFILE_PREFIX + PROPERTY_NAME_ID_TOKEN,
+ PROPERTY_NAME_ID_TOKEN
+ }) {
+ String encrypted = readEncryptedProperty(user, relPath);
+ if (encrypted != null) {
+ return decryptIdToken(encrypted, resolver.getUserID());
+ }
+ }
+
+ logger.debug("No id_token found for user {} in Oak", resolver.getUserID());
+ return null;
+ } catch (RepositoryException e) {
+ throw new OAuthException("Repository error reading id_token", e);
+ }
+ }
+
+ private @Nullable String readEncryptedProperty(@NotNull User user, @NotNull String relPath)
+ throws RepositoryException {
+ if (!user.hasProperty(relPath)) return null;
+ Value[] values = user.getProperty(relPath);
+ if (values == null || values.length == 0) return null;
+ String encrypted = values[0].getString();
+ return (encrypted != null && !encrypted.isEmpty()) ? encrypted : null;
+ }
+
+ private @Nullable String decryptIdToken(@NotNull String encrypted, @NotNull String safeUserId) {
+ try {
+ String decrypted = cryptoService.decrypt(encrypted);
+ logger.debug("Successfully retrieved and decrypted id_token for user {}", safeUserId);
+ return decrypted;
+ } catch (Exception e) {
+ logger.error(
+ "Failed to decrypt id_token for user {}. IdP may not properly invalidate session. Error: {}",
+ safeUserId,
+ e.getMessage(),
+ e);
+ return null;
+ }
+ }
+
private void setTokenProperty(
@NotNull User user,
@NotNull ValueFactory valueFactory,
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthException.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthException.java
index 2085d66..7fbd53f 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthException.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthException.java
@@ -26,6 +26,10 @@ public class OAuthException extends RuntimeException {
super(message);
}
+ public OAuthException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
public OAuthException(Throwable cause) {
super(cause);
}
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthTokenStore.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthTokenStore.java
index e58f420..827a289 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthTokenStore.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthTokenStore.java
@@ -21,6 +21,7 @@ package org.apache.sling.auth.oauth_client.impl;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.auth.oauth_client.ClientConnection;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
//
// In terms of what typed objects we expose, there are a number of ways
@@ -45,6 +46,8 @@ public interface OAuthTokenStore {
String PROPERTY_NAME_ACCESS_TOKEN = "access_token";
String PROPERTY_NAME_REFRESH_TOKEN = "refresh_token";
+ String PROPERTY_NAME_ID_TOKEN = "id_token";
+ String PROFILE_PREFIX = "profile/";
@NotNull
OAuthToken getAccessToken(@NotNull ClientConnection connection, @NotNull ResourceResolver resolver)
@@ -60,4 +63,16 @@ public interface OAuthTokenStore {
void clearAccessToken(@NotNull ClientConnection connection, @NotNull ResourceResolver resolver)
throws OAuthException;
+
+ /**
+ * Retrieves the ID token for the current user.
+ * This is primarily used during logout operations to read the id_token for OIDC RP-Initiated Logout.
+ *
+ * @param connection the client connection
+ * @param resolver the resource resolver for the current user
+ * @return the decrypted ID token, or null if not found or decryption fails
+ * @throws OAuthException if there is an error accessing the repository
+ */
+ @Nullable
+ String getIdToken(@NotNull ClientConnection connection, @NotNull ResourceResolver resolver) throws OAuthException;
}
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthTokens.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthTokens.java
index fa20514..5e1cd0a 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthTokens.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/OAuthTokens.java
@@ -25,11 +25,18 @@ public class OAuthTokens {
private final @Nullable String accessToken;
private final long expiresAt;
private final @Nullable String refreshToken;
+ private final @Nullable String idToken;
public OAuthTokens(@Nullable String accessToken, long expiresAt, @Nullable String refreshToken) {
+ this(accessToken, expiresAt, refreshToken, null);
+ }
+
+ public OAuthTokens(
+ @Nullable String accessToken, long expiresAt, @Nullable String refreshToken, @Nullable String idToken) {
this.accessToken = accessToken;
this.expiresAt = expiresAt;
this.refreshToken = refreshToken;
+ this.idToken = idToken;
}
@Nullable
@@ -45,4 +52,9 @@ public class OAuthTokens {
public String refreshToken() {
return refreshToken;
}
+
+ @Nullable
+ public String idToken() {
+ return idToken;
+ }
}
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcAuthenticationHandler.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcAuthenticationHandler.java
index b5b9174..91ac2b7 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcAuthenticationHandler.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcAuthenticationHandler.java
@@ -29,8 +29,10 @@ import java.net.URISyntaxException;
import java.net.URL;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
@@ -61,6 +63,8 @@ import com.nimbusds.openid.connect.sdk.claims.UserInfo;
import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator;
import org.apache.jackrabbit.oak.spi.security.authentication.credentials.CredentialsSupport;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityProvider;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.auth.core.AuthenticationSupport;
import org.apache.sling.auth.core.spi.AuthenticationHandler;
import org.apache.sling.auth.core.spi.AuthenticationInfo;
import org.apache.sling.auth.core.spi.DefaultAuthenticationFeedbackHandler;
@@ -113,8 +117,16 @@ public class OidcAuthenticationHandler extends DefaultAuthenticationFeedbackHand
private final int requestKeyCookieMaxAgeSeconds;
+ private final String logoutRedirectPath;
+
+ private final Set<String> logoutRedirectAllowedHosts;
+
+ private final boolean enableSPInitiatedSingleLogout;
+
private final CryptoService cryptoService;
+ private final OidcLogoutHandler logoutHandler;
+
@ObjectClassDefinition(
name = "Apache Sling Oidc Authentication Handler",
description = "Apache Sling Oidc Authentication Handler Service")
@@ -158,6 +170,32 @@ public class OidcAuthenticationHandler extends DefaultAuthenticationFeedbackHand
+ "callback. Default is 300 (5 minutes). Use a higher value if users often take longer "
+ "(e.g. consent, 2FA).")
int requestKeyCookieMaxAgeSeconds() default RedirectHelper.DEFAULT_REQUEST_KEY_COOKIE_MAX_AGE_SECONDS;
+
+ @AttributeDefinition(
+ name = "Enable SP-initiated single logout",
+ description = "Enable SP-initiated single logout to redirect to the IdP's end_session_endpoint "
+ + "during logout. When enabled, users are redirected to the IdP for logout (if the IdP "
+ + "supports it via an end_session_endpoint). When disabled, logout only clears local "
+ + "session without notifying the IdP. SECURITY: When enabled, 'logoutRedirectAllowedHosts' "
+ + "MUST be configured to prevent open redirect vulnerabilities. Default is false.")
+ boolean enableSPInitiatedSingleLogout() default false;
+
+ @AttributeDefinition(
+ name = "Logout redirect path",
+ description = "Path to redirect to after logout. Used as the post_logout_redirect_uri when "
+ + "SP-initiated single logout is enabled, and as the local redirect path when it is "
+ + "disabled or the IdP does not support single logout. Default is \"/\".")
+ String logoutRedirectPath() default "/";
+
+ @AttributeDefinition(
+ name = "Logout redirect allowed hosts",
+ description = "REQUIRED when 'enableSPInitiatedSingleLogout' is true. List of allowed host names "
+ + "for the post_logout_redirect_uri sent to the IdP. The redirect URI is only sent if its "
+ + "host is in this list; otherwise a URI with the first allowed host is used. This prevents "
+ + "open redirect attacks when the request Host header is spoofed. When SP-initiated single "
+ + "logout is enabled and this is empty, the service will fail to activate with an exception.",
+ cardinality = Integer.MAX_VALUE)
+ String[] logoutRedirectAllowedHosts() default {};
}
@Activate
@@ -167,7 +205,8 @@ public class OidcAuthenticationHandler extends DefaultAuthenticationFeedbackHand
Config config,
@Reference(policyOption = ReferencePolicyOption.GREEDY) LoginCookieManager loginCookieManager,
@Reference(policyOption = ReferencePolicyOption.GREEDY) List<UserInfoProcessor> userInfoProcessors,
- @Reference CryptoService cryptoService) {
+ @Reference CryptoService cryptoService,
+ @Reference OidcLogoutHandler logoutHandler) {
this.connections = connections.stream().collect(Collectors.toMap(ClientConnection::name, Function.identity()));
this.idp = config.idp();
@@ -181,7 +220,25 @@ public class OidcAuthenticationHandler extends DefaultAuthenticationFeedbackHand
this.path = config.path();
this.resource = config.resource();
this.requestKeyCookieMaxAgeSeconds = config.requestKeyCookieMaxAgeSeconds();
+ this.enableSPInitiatedSingleLogout = config.enableSPInitiatedSingleLogout();
+ this.logoutRedirectPath = config.logoutRedirectPath();
+ this.logoutRedirectAllowedHosts = config.logoutRedirectAllowedHosts() != null
+ ? Stream.of(config.logoutRedirectAllowedHosts())
+ .filter(h -> h != null && !h.isEmpty())
+ .map(String::toLowerCase)
+ .collect(Collectors.toSet())
+ : Set.of();
this.cryptoService = cryptoService;
+ this.logoutHandler = logoutHandler;
+
+ // Security validation: enforce allowed hosts when SP-initiated single logout is enabled
+ if (this.enableSPInitiatedSingleLogout && this.logoutRedirectAllowedHosts.isEmpty()) {
+ throw new IllegalArgumentException(
+ "SECURITY: SP-initiated single logout is enabled but 'logoutRedirectAllowedHosts' is not configured. "
+ + "This would make logout vulnerable to open redirect attacks via Host header spoofing. "
+ + "You must configure 'logoutRedirectAllowedHosts' with at least one allowed host when "
+ + "'enableSPInitiatedSingleLogout' is true.");
+ }
logger.debug("activate: registering ExternalIdentityProvider");
bundleContext.registerService(
@@ -565,7 +622,54 @@ public class OidcAuthenticationHandler extends DefaultAuthenticationFeedbackHand
@Override
public void dropCredentials(HttpServletRequest request, HttpServletResponse response) {
- // TODO: perform logout from Sling and redirect?
+ // Clear login cookie
+ if (loginCookieManager != null) {
+ loginCookieManager.clearLoginCookie(request, response);
+ }
+
+ // Only perform redirect to IdP if SP-initiated single logout is enabled
+ if (!enableSPInitiatedSingleLogout) {
+ logger.debug("SP-initiated single logout is disabled; logout completed without redirect");
+ return;
+ }
+
+ // Resolve the connection used for this user's session
+ ClientConnection connection =
+ OidcLogoutHandler.resolveConnectionForLogout(this.connections, this.defaultConnectionName);
+ if (connection == null) {
+ logger.error("enableSPInitiatedSingleLogout set to true but no OIDC connection is available.");
+ return;
+ }
+
+ // If endSessionEndpoint is not configured dropCredentials will not continue
+ URI endSessionEndpoint = logoutHandler.getEndSessionEndpoint(connection);
+ if (endSessionEndpoint == null) {
+ logger.error("enableSPInitiatedSingleLogout set to true but no end_session_endpoint configured.");
+ return;
+ }
+
+ // SP-initiated single logout is enabled - proceed with redirect
+ if (response.isCommitted()) {
+ logger.debug("Response already committed (e.g. by another auth handler); skipping redirect");
+ return;
+ }
+
+ ResourceResolver resolver =
+ (ResourceResolver) request.getAttribute(AuthenticationSupport.REQUEST_ATTRIBUTE_RESOLVER);
+ String idTokenHint = logoutHandler.getIdTokenFromTokenStore(resolver, connection);
+
+ // Get redirect parameter from request (if provided)
+ String redirectParameter = request.getParameter(RedirectHelper.PARAMETER_NAME_REDIRECT);
+ String redirectPath = logoutHandler.buildPostLogoutRedirectUri(
+ request, redirectParameter, this.logoutRedirectPath, this.logoutRedirectAllowedHosts);
+
+ try {
+ String logoutUrl = OidcLogoutHandler.buildLogoutUrl(endSessionEndpoint, redirectPath, idTokenHint);
+ logger.debug("Redirecting to IdP end_session_endpoint for single logout: {}", logoutUrl);
+ response.sendRedirect(logoutUrl);
+ } catch (IOException e) {
+ throw new OidcAuthenticationHandlerException("Error while redirecting during logout", e);
+ }
}
@Override
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcConnectionImpl.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcConnectionImpl.java
index 04bd431..487e884 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcConnectionImpl.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcConnectionImpl.java
@@ -61,6 +61,12 @@ public class OidcConnectionImpl implements ClientConnection {
String[] additionalAuthorizationParameters();
+ @AttributeDefinition(
+ name = "End Session Endpoint",
+ description = "Optional OIDC end_session_endpoint URL for SP-initiated logout. When using baseUrl, "
+ + "this is read from provider metadata.")
+ String endSessionEndpoint() default "";
+
String webconsole_configurationFactory_nameHint() default
"Name: {name}, base URL: {baseUrl}, clientId: {clientId}";
}
@@ -204,4 +210,29 @@ public class OidcConnectionImpl implements ClientConnection {
}
return issuer;
}
+
+ /**
+ * Returns the OIDC end_session_endpoint URI for SP-initiated single logout, or null if not available.
+ */
+ @Nullable
+ URI endSessionEndpoint() {
+ if (hasBaseUrl && metadataRegistry != null) {
+ URI fromMetadata = metadataRegistry.getEndSessionEndpointURI(cfg.baseUrl());
+ if (fromMetadata != null) {
+ return fromMetadata;
+ }
+ }
+ String url = cfg.endSessionEndpoint();
+ if (url != null && !url.isEmpty()) {
+ try {
+ return URI.create(url);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalStateException(
+ String.format(
+ "Invalid end_session_endpoint URL configured: '%s'. Error: %s", url, e.getMessage()),
+ e);
+ }
+ }
+ return null;
+ }
}
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcLogoutHandler.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcLogoutHandler.java
new file mode 100644
index 0000000..d1795ae
--- /dev/null
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcLogoutHandler.java
@@ -0,0 +1,315 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.sling.auth.oauth_client.impl;
+
+import javax.servlet.http.HttpServletRequest;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.auth.oauth_client.ClientConnection;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.annotations.ReferenceCardinality;
+import org.osgi.service.component.annotations.ReferencePolicyOption;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Handles OIDC logout operations including IdP session termination.
+ * This class encapsulates the logic for SP-initiated single logout as defined by the
+ * OIDC RP-Initiated Logout specification.
+ */
+@Component(service = OidcLogoutHandler.class)
+public class OidcLogoutHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(OidcLogoutHandler.class);
+ private static final String ROOT_PATH = "/";
+
+ private final OAuthTokenStore tokenStore;
+
+ @Activate
+ public OidcLogoutHandler(
+ @Reference(cardinality = ReferenceCardinality.OPTIONAL, policyOption = ReferencePolicyOption.GREEDY)
+ OAuthTokenStore tokenStore) {
+ this.tokenStore = tokenStore;
+ }
+
+ /**
+ * Builds the post_logout_redirect_uri sent to the IdP. When logoutRedirectAllowedHosts is set,
+ * the resulting URI's host is validated to prevent open redirect (Host header spoofing); if not allowed,
+ * the first allowed host is used instead.
+ *
+ * @param request the HTTP request
+ * @param redirectParameter optional redirect parameter from the request (e.g., query parameter)
+ * @return the post-logout redirect URI
+ */
+ @NotNull
+ String buildPostLogoutRedirectUri(
+ HttpServletRequest request,
+ @Nullable String redirectParameter,
+ @NotNull String logoutRedirectPath,
+ @NotNull Set<String> logoutRedirectAllowedHosts) {
+ // Try to use the redirect parameter if provided and valid
+ if (redirectParameter != null && !redirectParameter.isEmpty()) {
+ String validatedRedirect =
+ validateAndBuildRedirectFromParameter(redirectParameter, request, logoutRedirectAllowedHosts);
+ if (validatedRedirect != null) {
+ return validatedRedirect;
+ }
+ // If validation failed, fall through to use configured logoutRedirectPath
+ logger.debug(
+ "Redirect parameter '{}' failed validation; falling back to configured logoutRedirectPath",
+ redirectParameter);
+ }
+
+ // Use configured logoutRedirectPath
+ String redirectPath = logoutRedirectPath;
+ if (redirectPath.isEmpty()) {
+ redirectPath = ROOT_PATH;
+ }
+ if (!redirectPath.startsWith(ROOT_PATH)) {
+ redirectPath = ROOT_PATH + redirectPath;
+ }
+ // Note: Context path handling omitted - Sling always deploys at root context ("/")
+ int port = request.getServerPort();
+ String scheme = request.getScheme();
+ String host = request.getServerName();
+
+ // Validate host against allow list (guaranteed non-empty when SP-initiated logout is enabled)
+ // This prevents Host header spoofing attacks
+ try {
+ String hostLower = host.toLowerCase();
+ if (!logoutRedirectAllowedHosts.contains(hostLower)) {
+ String safeHost = logoutRedirectAllowedHosts.iterator().next();
+ logger.debug(
+ "Post-logout redirect host '{}' not in allowed list; using '{}' to prevent open redirect",
+ host,
+ safeHost);
+ host = safeHost;
+ }
+ } catch (IllegalArgumentException e) {
+ logger.warn("Invalid redirect URI for logout: {}", e.getMessage());
+ // Fall back to first allowed host
+ host = logoutRedirectAllowedHosts.iterator().next();
+ }
+
+ return UriBuilder.buildRedirectUri(scheme, host, port, redirectPath);
+ }
+
+ /**
+ * Validates the redirect parameter and builds a full redirect URI if valid.
+ * The redirect parameter MUST be a relative path (starting with "/" but not "//").
+ * Absolute URLs are rejected for security reasons to prevent open redirect vulnerabilities.
+ * The relative path is converted to an absolute URL using the request's scheme/host/port.
+ *
+ * @param redirectParameter the redirect parameter value (must be a relative path)
+ * @param request the HTTP request
+ * @return the validated redirect URI, or null if validation fails
+ */
+ @Nullable
+ private String validateAndBuildRedirectFromParameter(
+ @NotNull String redirectParameter,
+ @NotNull HttpServletRequest request,
+ @NotNull Set<String> logoutRedirectAllowedHosts) {
+ try {
+ // Validate that it's a relative path, not an absolute URL
+ // Must start with "/" but not "//" (which could be a protocol-relative URL)
+ if (!redirectParameter.startsWith("/") || redirectParameter.startsWith("//")) {
+ logger.debug(
+ "Redirect parameter '{}' is not a valid relative path (must start with / but not //); rejecting",
+ redirectParameter);
+ return null;
+ }
+
+ // Parse to ensure it's a valid URI structure
+ URI uri = new URI(redirectParameter);
+
+ // Reject if it's an absolute URI (has scheme)
+ if (uri.isAbsolute()) {
+ logger.debug(
+ "Redirect parameter '{}' is an absolute URL; only relative paths are allowed; rejecting",
+ redirectParameter);
+ return null;
+ }
+
+ // Get the path component
+ String path = uri.getPath();
+ if (path == null || path.isEmpty()) {
+ logger.debug("Redirect parameter '{}' has no path; rejecting", redirectParameter);
+ return null;
+ }
+
+ // Build absolute URL from relative path
+ // Note: Context path handling omitted - Sling always deploys at root context ("/")
+ String scheme = request.getScheme();
+ String host = request.getServerName();
+ int port = request.getServerPort();
+
+ // Validate request host against allow list (guaranteed non-empty when SP-initiated logout is enabled)
+ // This prevents Host header spoofing attacks
+ String hostLower = host.toLowerCase();
+ if (!logoutRedirectAllowedHosts.contains(hostLower)) {
+ // Use the first allowed host instead of the request host
+ String safeHost = logoutRedirectAllowedHosts.iterator().next();
+ logger.debug(
+ "Request host '{}' not in allowed list; using '{}' for redirect parameter", host, safeHost);
+ host = safeHost;
+ }
+
+ return UriBuilder.buildRedirectUri(scheme, host, port, path);
+ } catch (URISyntaxException e) {
+ logger.debug(
+ "Redirect parameter '{}' is not a valid URI; rejecting: {}", redirectParameter, e.getMessage());
+ return null;
+ } catch (IllegalArgumentException e) {
+ logger.debug("Failed to build redirect URI from parameter '{}': {}", redirectParameter, e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Resolves the client connection to use for logout operations.
+ *
+ * @param connections map of available connections
+ * @param defaultConnectionName preferred connection name (may be empty)
+ * @return the client connection, or null if none available
+ */
+ @Nullable
+ static ClientConnection resolveConnectionForLogout(
+ @NotNull Map<String, ClientConnection> connections, @Nullable String defaultConnectionName) {
+ if (defaultConnectionName != null && !defaultConnectionName.isEmpty()) {
+ return connections.get(defaultConnectionName);
+ }
+ return connections.isEmpty() ? null : connections.values().iterator().next();
+ }
+
+ /**
+ * Extracts the end_session_endpoint from the OIDC connection.
+ *
+ * @param connection the client connection
+ * @return the end session endpoint URI, or null if not available
+ */
+ @Nullable
+ URI getEndSessionEndpoint(@NotNull ClientConnection connection) {
+ if (connection instanceof OidcConnectionImpl) {
+ return ((OidcConnectionImpl) connection).endSessionEndpoint();
+ }
+ return null;
+ }
+
+ /**
+ * Reads the id_token for the current user, for use as id_token_hint at the IdP end_session_endpoint.
+ *
+ * @param resolver the resource resolver for the current user
+ * @param connection the resolved client connection to use with the token store
+ * @return the id_token string, or null if not found or on error
+ */
+ @Nullable
+ String getIdTokenFromTokenStore(@Nullable ResourceResolver resolver, @Nullable ClientConnection connection) {
+ if (tokenStore == null || resolver == null || connection == null) {
+ return null;
+ }
+ try {
+ return tokenStore.getIdToken(connection, resolver);
+ } catch (OAuthException e) {
+ logger.warn("Error reading id_token for user '{}': {}", resolver.getUserID(), e.getMessage(), e);
+ return null;
+ }
+ }
+
+ /**
+ * Builds the logout URL with OIDC RP-Initiated Logout parameters.
+ *
+ * @param endSessionEndpoint the IdP's end_session_endpoint
+ * @param postLogoutRedirectUri where to redirect after logout
+ * @param idTokenHint the ID token to use as hint (optional)
+ * @return the complete logout URL
+ */
+ @NotNull
+ static String buildLogoutUrl(
+ @NotNull URI endSessionEndpoint, @NotNull String postLogoutRedirectUri, @Nullable String idTokenHint) {
+ String encodedRedirect = URLEncoder.encode(postLogoutRedirectUri, StandardCharsets.UTF_8);
+ String endSessionStr = endSessionEndpoint.toString();
+ StringBuilder sb = new StringBuilder(endSessionStr).append(endSessionStr.contains("?") ? "&" : "?");
+ if (idTokenHint != null && !idTokenHint.isEmpty()) {
+ sb.append("id_token_hint=")
+ .append(URLEncoder.encode(idTokenHint, StandardCharsets.UTF_8))
+ .append("&");
+ }
+ sb.append("post_logout_redirect_uri=").append(encodedRedirect);
+ return sb.toString();
+ }
+
+ /**
+ * Helper class for building URIs.
+ */
+ static class UriBuilder {
+
+ private UriBuilder() {}
+
+ /**
+ * Builds a redirect URI string from components.
+ *
+ * @param scheme the URI scheme (http/https)
+ * @param host the hostname
+ * @param port the port number
+ * @param path the path component
+ * @return the constructed URI string
+ * @throws IllegalArgumentException if the URI is invalid
+ */
+ @NotNull
+ static String buildRedirectUri(@NotNull String scheme, @NotNull String host, int port, @Nullable String path) {
+ if (scheme.isEmpty()) {
+ throw new IllegalArgumentException("Scheme cannot be empty");
+ }
+ if (host.isEmpty()) {
+ throw new IllegalArgumentException("Host cannot be empty");
+ }
+ if (path == null) {
+ path = ROOT_PATH;
+ }
+
+ boolean defaultPort = (scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443);
+ StringBuilder sb = new StringBuilder();
+ sb.append(scheme).append("://").append(host);
+ if (!defaultPort) {
+ sb.append(':').append(port);
+ }
+ sb.append(path);
+
+ // Validate the constructed URI
+ String uriString = sb.toString();
+ try {
+ URI.create(uriString);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid redirect URI constructed: " + uriString, e);
+ }
+ return uriString;
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcProviderMetadataRegistry.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcProviderMetadataRegistry.java
index c16b2a2..f895eb6 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcProviderMetadataRegistry.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/OidcProviderMetadataRegistry.java
@@ -27,6 +27,7 @@ import com.nimbusds.oauth2.sdk.GeneralException;
import com.nimbusds.oauth2.sdk.id.Issuer;
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import org.osgi.service.component.annotations.Component;
/**
@@ -71,4 +72,15 @@ public class OidcProviderMetadataRegistry {
public @NotNull String getIssuer(@NotNull String base) {
return getProviderMetadata(base).getIssuer().getValue();
}
+
+ /**
+ * Returns the end_session_endpoint URI from the OIDC provider metadata, if advertised.
+ * Used for SP-initiated single logout (RP-Initiated Logout).
+ *
+ * @param base the IdP base URL (issuer)
+ * @return the end session endpoint URI, or null if not advertised by the provider
+ */
+ public @Nullable URI getEndSessionEndpointURI(@NotNull String base) {
+ return getProviderMetadata(base).getEndSessionEndpointURI();
+ }
}
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/RedisOAuthTokenStore.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/RedisOAuthTokenStore.java
index 3087af3..6b7b9ca 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/RedisOAuthTokenStore.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/RedisOAuthTokenStore.java
@@ -46,6 +46,7 @@ public class RedisOAuthTokenStore implements OAuthTokenStore {
private static final String KEY_SEGMENT_ACCESS_TOKEN = "access_token";
private static final String KEY_SEGMENT_REFRESH_TOKEN = "refresh_token";
+ private static final String KEY_SEGMENT_ID_TOKEN = "id_token";
private final JedisPool pool;
@@ -107,6 +108,7 @@ public class RedisOAuthTokenStore implements OAuthTokenStore {
tokens.expiresAt());
if (tokens.refreshToken() != null)
jedis.set(keyFor(userId, connection, KEY_SEGMENT_REFRESH_TOKEN), tokens.refreshToken());
+ if (tokens.idToken() != null) jedis.set(keyFor(userId, connection, KEY_SEGMENT_ID_TOKEN), tokens.idToken());
}
}
@@ -120,6 +122,15 @@ public class RedisOAuthTokenStore implements OAuthTokenStore {
}
}
+ @Override
+ public @Nullable String getIdToken(@NotNull ClientConnection connection, @NotNull ResourceResolver resolver)
+ throws OAuthException {
+ String userId = resolver.getUserID();
+ try (Jedis jedis = pool.getResource()) {
+ return jedis.get(keyFor(userId, connection, KEY_SEGMENT_ID_TOKEN));
+ }
+ }
+
private static void setWithExpiry(@NotNull Jedis jedis, @NotNull String key, @Nullable String value, long expiry) {
jedis.set(key, value);
if (expiry > 0) jedis.expire(key, expiry);
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/ResolvedOidcConnection.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/ResolvedOidcConnection.java
index 7cbb34b..ab77703 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/ResolvedOidcConnection.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/ResolvedOidcConnection.java
@@ -30,6 +30,7 @@ class ResolvedOidcConnection extends ResolvedConnection {
private final URI jwkSetURL;
private final String issuer;
+ private final URI endSessionEndpoint;
/**
* Constructs a resolved OIDC connection with all parameters materialised.
@@ -43,6 +44,7 @@ class ResolvedOidcConnection extends ResolvedConnection {
* @param additionalAuthorizationParameters additional authorization parameters
* @param jwkSetURL the JWK Set URL, may be null
* @param issuer the issuer URL
+ * @param endSessionEndpoint the end_session endpoint URL for logout, may be null
*/
private ResolvedOidcConnection(
@NotNull String name,
@@ -53,7 +55,8 @@ class ResolvedOidcConnection extends ResolvedConnection {
@NotNull List<String> scopes,
@NotNull List<String> additionalAuthorizationParameters,
@Nullable URI jwkSetURL,
- @NotNull String issuer) {
+ @NotNull String issuer,
+ @Nullable URI endSessionEndpoint) {
super(
name,
authorizationEndpoint,
@@ -64,6 +67,7 @@ class ResolvedOidcConnection extends ResolvedConnection {
additionalAuthorizationParameters);
this.jwkSetURL = jwkSetURL;
this.issuer = issuer;
+ this.endSessionEndpoint = endSessionEndpoint;
}
@Nullable
@@ -76,6 +80,14 @@ class ResolvedOidcConnection extends ResolvedConnection {
return issuer;
}
+ /**
+ * Returns the OIDC end_session_endpoint URI for SP-initiated single logout, or null if not available.
+ */
+ @Nullable
+ URI endSessionEndpoint() {
+ return endSessionEndpoint;
+ }
+
static @NotNull ResolvedConnection resolve(@NotNull ClientConnection connection) {
if (connection instanceof OidcConnectionImpl) {
OidcConnectionImpl impl = (OidcConnectionImpl) connection;
@@ -88,7 +100,8 @@ class ResolvedOidcConnection extends ResolvedConnection {
Arrays.asList(impl.scopes()),
Arrays.asList(impl.additionalAuthorizationParameters()),
impl.jwkSetURL(),
- impl.issuer());
+ impl.issuer(),
+ impl.endSessionEndpoint());
}
throw new IllegalArgumentException(String.format(
"Unable to resolve %s (name=%s) of type %s",
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingLoginCookieManager.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingLoginCookieManager.java
index 54ba4a0..a719055 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingLoginCookieManager.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingLoginCookieManager.java
@@ -139,6 +139,11 @@ public class SlingLoginCookieManager implements LoginCookieManager {
return null;
}
+ @Override
+ public void clearLoginCookie(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response) {
+ setCookie(request, response, cookieName, "", 0);
+ }
+
private @Nullable AuthenticationInfo createAuthInfo(@NotNull final String authData) {
final String userId = getUserId(authData);
if (userId == null) {
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImpl.java b/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImpl.java
index f93e0d6..9d8afb7 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImpl.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImpl.java
@@ -62,6 +62,13 @@ public class SlingUserInfoProcessorImpl implements UserInfoProcessor {
@AttributeDefinition(name = "storeRefreshToken", description = "Store access Refresh under User Node")
boolean storeRefreshToken() default false;
+ @AttributeDefinition(
+ name = "storeIdToken",
+ description =
+ "Store the ID Token in credentials for use during logout (id_token_hint at IdP end_session_endpoint). "
+ + "Requires the login cookie manager to persist it (e.g. SlingLoginCookieManager).")
+ boolean storeIdToken() default false;
+
@AttributeDefinition(
name = "groupsClaimName",
description = "Name of the claim in the ID Token or UserInfo that contains the groups. "
@@ -82,6 +89,7 @@ public class SlingUserInfoProcessorImpl implements UserInfoProcessor {
private final CryptoService cryptoService;
private final boolean storeAccessToken;
private final boolean storeRefreshToken;
+ private final boolean storeIdToken;
private final boolean groupsInIdToken;
private final String groupsClaimName;
private final String connection;
@@ -93,6 +101,7 @@ public class SlingUserInfoProcessorImpl implements UserInfoProcessor {
this.cryptoService = service;
this.storeAccessToken = config.storeAccessToken();
this.storeRefreshToken = config.storeRefreshToken();
+ this.storeIdToken = config.storeIdToken();
this.groupsInIdToken = config.groupsInIdToken();
this.groupsClaimName = config.groupsClaimName();
if (config.connection() == null || config.connection().isEmpty()) {
@@ -129,27 +138,17 @@ public class SlingUserInfoProcessorImpl implements UserInfoProcessor {
// If groups are not in ID Token, add them from UserInfo
userInfo.toJSONObject().forEach((key, value) -> {
if (value != null) {
- credentials.setAttribute("profile/" + key, value.toString());
+ credentials.setAttribute(OAuthTokenStore.PROFILE_PREFIX + key, value.toString());
}
});
}
if (groupsInIdToken) {
// If groups are in ID Token, add them to the credentials
- try {
- Object groups = tokenResponse
- .toSuccessResponse()
- .getTokens()
- .toOIDCTokens()
- .getIDToken()
- .getJWTClaimsSet()
- .getClaim(groupsClaimName);
- if (groups instanceof List) {
- logger.debug("Groups from ID Token: {}", groups);
- ((List) groups).forEach(group -> credentials.addGroup(getGroupName(idp, group)));
- }
- } catch (java.text.ParseException e) {
- throw new RuntimeException(e);
+ Object groups = Converter.extractIdTokenClaim(tokens.idToken(), groupsClaimName);
+ if (groups instanceof List) {
+ logger.debug("Groups from ID Token: {}", groups);
+ ((List) groups).forEach(group -> credentials.addGroup(getGroupName(idp, group)));
}
} else if (userInfo != null) {
// If groups are not in ID Token, check UserInfo for groups
@@ -181,6 +180,24 @@ public class SlingUserInfoProcessorImpl implements UserInfoProcessor {
OAuthTokenStore.PROPERTY_NAME_REFRESH_TOKEN);
}
+ // Store the ID Token for logout (id_token_hint at IdP end_session_endpoint)
+ // SECURITY NOTE: The ID token is encrypted before storage but increases the attack surface.
+ // Ensure proper access controls on user profile storage and rotation of encryption keys.
+ String idToken = tokens.idToken();
+ if (storeIdToken && idToken != null && !idToken.isEmpty()) {
+ try {
+ credentials.setAttribute(OAuthTokenStore.PROPERTY_NAME_ID_TOKEN, cryptoService.encrypt(idToken));
+ logger.debug("ID token stored (encrypted) for logout support");
+ } catch (RuntimeException e) {
+ logger.error("Failed to encrypt ID token for logout: {}", e.getMessage(), e);
+ }
+ } else if (storeIdToken) {
+ logger.debug("ID token is null or empty; cannot store for logout");
+ }
+
+ // The returned credentials are passed to Oak's LoginModule, which delegates to a registered
+ // SynchronizationHandler (e.g. DefaultSyncHandler) to create or update the JCR user and
+ // synchronize group memberships based on the attributes and groups set above.
return credentials;
}
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/spi/LoginCookieManager.java b/src/main/java/org/apache/sling/auth/oauth_client/spi/LoginCookieManager.java
index 1a802f4..a2ad5e4 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/spi/LoginCookieManager.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/spi/LoginCookieManager.java
@@ -56,4 +56,13 @@ public interface LoginCookieManager {
*/
@Nullable
Cookie getLoginCookie(@NotNull HttpServletRequest request);
+
+ /**
+ * Clear the login cookie in the response so the user is logged out locally.
+ * Used for SP-initiated single logout.
+ *
+ * @param request the request (used to determine cookie path and secure flag)
+ * @param response the response to add the clearing cookie to
+ */
+ void clearLoginCookie(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response);
}
diff --git a/src/main/java/org/apache/sling/auth/oauth_client/spi/UserInfoProcessor.java b/src/main/java/org/apache/sling/auth/oauth_client/spi/UserInfoProcessor.java
index dcb9dea..1561f55 100644
--- a/src/main/java/org/apache/sling/auth/oauth_client/spi/UserInfoProcessor.java
+++ b/src/main/java/org/apache/sling/auth/oauth_client/spi/UserInfoProcessor.java
@@ -35,12 +35,7 @@ public interface UserInfoProcessor {
* @param tokenResponse the token response received from the identity provider, must not be null. See: https://openid.net/specs/openid-connect-core-1_0.html#HybridTokenResponse
* @param oidcSubject the OIDC subject identifier as defined in ID Token, must not be null
* @param idp the identity provider identifier as defined in OidcAuthenticationHandler configuration, must not be null
- * @param userInfo
- * @param tokenResponse
- * @param oidcSubject
- * @param idp
* @return the credentials to be returned by the authentication handler, must not be null
- * @return
*/
@NotNull
OidcAuthCredentials process(
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/AuthorizationCodeFlowIT.java b/src/test/java/org/apache/sling/auth/oauth_client/AuthorizationCodeFlowIT.java
index aafab2e..494e23c 100644
--- a/src/test/java/org/apache/sling/auth/oauth_client/AuthorizationCodeFlowIT.java
+++ b/src/test/java/org/apache/sling/auth/oauth_client/AuthorizationCodeFlowIT.java
@@ -37,6 +37,7 @@ import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -75,6 +76,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static java.lang.String.format;
+import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -100,6 +102,7 @@ class AuthorizationCodeFlowIT {
private static final String EXTERNAL_LOGIN_MODULE_FACTORY_PID =
"org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalLoginModuleFactory";
public static final String TEST_PATH = "/content/test-1";
+ private static final String LOGOUT_PATH = "/system/sling/logout";
private KeycloakContainer keycloak;
private SlingClient sling;
private SlingClient slingUser;
@@ -137,6 +140,8 @@ class AuthorizationCodeFlowIT {
void initSling() throws ClientException, InterruptedException, TimeoutException {
slingPort = Integer.getInteger("sling.http.port", 8080);
+ waitForSlingReady(slingPort);
+
sling = SlingClient.Builder.create(URI.create("http://localhost:" + slingPort), "admin", "admin")
.disableRedirectHandling()
.build();
@@ -171,6 +176,31 @@ class AuthorizationCodeFlowIT {
supportBundle.uninstall(sling.adaptTo(OsgiConsoleClient.class));
}
+ /**
+ * Waits until Sling is accepting HTTP connections on the given port. This avoids "connection refused"
+ * when the feature-launcher has signalled readiness slightly before the server is fully up.
+ */
+ private static void waitForSlingReady(int port) {
+ int timeoutSeconds = Integer.getInteger("it.startTimeoutSeconds", 60);
+ URI baseUri = URI.create("http://localhost:" + port + "/");
+ await().atMost(timeoutSeconds, SECONDS)
+ .pollInterval(500, TimeUnit.MILLISECONDS)
+ .until(() -> {
+ try {
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(2))
+ .build();
+ HttpRequest request =
+ HttpRequest.newBuilder().uri(baseUri).GET().build();
+ HttpResponse<String> response =
+ client.send(request, BodyHandlers.ofString(StandardCharsets.UTF_8));
+ return response.statusCode() >= 200 && response.statusCode() < 500;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+
@Test
void accessTokenIsPresentOnSuccessfulLogin() throws Exception {
@@ -379,6 +409,8 @@ class AuthorizationCodeFlowIT {
.editConfiguration(SlingLoginCookieManager.class.getName(), null, Map.of("idpName", "oidc-idp")));
String oidcConnectionName = "keycloak";
+ String keycloakBaseUrl = "http://localhost:" + keycloakPort + "/realms/sling";
+ String keycloakEndSessionUrl = keycloakBaseUrl + "/protocol/openid-connect/logout";
// configure connection to keycloak
if (withPkce) {
@@ -390,13 +422,15 @@ class AuthorizationCodeFlowIT {
"name",
oidcConnectionName,
"baseUrl",
- "http://localhost:" + keycloakPort + "/realms/sling",
+ keycloakBaseUrl,
"clientId",
"oidc-pkce",
"pkceEnabled",
"true",
"scopes",
- new String[] {"openid"})));
+ new String[] {"openid"},
+ "endSessionEndpoint",
+ keycloakEndSessionUrl)));
} else {
configPidsToCleanup.add(sling.adaptTo(OsgiConsoleClient.class)
.editConfiguration(
@@ -406,13 +440,15 @@ class AuthorizationCodeFlowIT {
"name",
oidcConnectionName,
"baseUrl",
- "http://localhost:" + keycloakPort + "/realms/sling",
+ keycloakBaseUrl,
"clientId",
"oidc-test",
"clientSecret",
"wM2XIbxBTLJAac2rJSuHyKaoP8IWvSwJ",
"scopes",
- new String[] {"openid"})));
+ new String[] {"openid"},
+ "endSessionEndpoint",
+ keycloakEndSessionUrl)));
}
configPidsToCleanup.add(sling.adaptTo(OsgiConsoleClient.class)
.editConfiguration(
@@ -460,6 +496,7 @@ class AuthorizationCodeFlowIT {
Map.of(
"storeAccessToken", "true",
"storeRefreshToken", "true",
+ "storeIdToken", "true",
"connection", oidcConnectionName)));
HashMap<String, Object> authenticationHandlerConfig = new HashMap<>();
@@ -468,7 +505,9 @@ class AuthorizationCodeFlowIT {
authenticationHandlerConfig.put("defaultRedirect", TEST_PATH + ".html");
authenticationHandlerConfig.put(
"callbackUri", "http://localhost:" + slingPort + TEST_PATH + "/j_security_check");
-
+ authenticationHandlerConfig.put("logoutRedirectPath", "/");
+ authenticationHandlerConfig.put("enableSPInitiatedSingleLogout", "true");
+ authenticationHandlerConfig.put("logoutRedirectAllowedHosts", new String[] {"localhost"});
authenticationHandlerConfig.put("pkceEnabled", Boolean.toString(withPkce));
authenticationHandlerConfig.put("idp", "oidc-idp");
@@ -574,11 +613,15 @@ class AuthorizationCodeFlowIT {
.collect(Collectors.toList());
headers = new ArrayList<>();
- String cookieHeader = "";
+ StringBuilder cookieHeader = new StringBuilder();
for (Cookie cookie : cookies) {
- cookieHeader += cookie.getName() + "=" + cookie.getValue() + "; ";
+ cookieHeader
+ .append(cookie.getName())
+ .append("=")
+ .append(cookie.getValue())
+ .append("; ");
}
- headers.add(new BasicHeader("Cookie", cookieHeader));
+ headers.add(new BasicHeader("Cookie", cookieHeader.toString()));
SlingHttpResponse authenticatedResponse = slingUser.doGet(redirectUri.getRawPath(), params, headers, 302);
Header[] cookieHeaders = authenticatedResponse.getHeaders("set-cookie");
// retrieve the login-cookie header
@@ -592,6 +635,23 @@ class AuthorizationCodeFlowIT {
// Get the page from sling with login cookie
slingUser.doGet(TEST_PATH + ".json", new ArrayList<>(), headers, 200);
+
+ // Single logout: GET /system/sling/logout?resource=<protected path> so the Authenticator
+ // invokes the OIDC handler (getHandlerSelectionPath uses the resource param via LOGIN_RESOURCE).
+ List<NameValuePair> logoutParams = List.of(new BasicNameValuePair("resource", TEST_PATH));
+ SlingHttpResponse logoutResponse = slingUser.doGet(LOGOUT_PATH, logoutParams, headers, 302);
+ Header logoutLocation = logoutResponse.getFirstHeader("location");
+ assertThat(logoutLocation).as("Logout redirect Location").isNotNull();
+ String location = logoutLocation.getValue();
+ assertThat(location)
+ .as("Location must redirect to Keycloak end_session endpoint")
+ .startsWith(keycloakEndSessionUrl);
+ assertThat(location)
+ .as("Location must contain post_logout_redirect_uri")
+ .contains("post_logout_redirect_uri=");
+ assertThat(location)
+ .as("post_logout_redirect_uri should point to configured logout path")
+ .contains(URLEncoder.encode("http://localhost:" + slingPort + "/", StandardCharsets.UTF_8));
}
private String getUserPath(SlingClient sling, String authorizableId) throws ClientException {
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/InMemoryOAuthTokenStore.java b/src/test/java/org/apache/sling/auth/oauth_client/InMemoryOAuthTokenStore.java
index 9136408..d6d923f 100644
--- a/src/test/java/org/apache/sling/auth/oauth_client/InMemoryOAuthTokenStore.java
+++ b/src/test/java/org/apache/sling/auth/oauth_client/InMemoryOAuthTokenStore.java
@@ -30,6 +30,7 @@ import org.apache.sling.auth.oauth_client.impl.OAuthTokenStore;
import org.apache.sling.auth.oauth_client.impl.OAuthTokens;
import org.apache.sling.auth.oauth_client.impl.TokenState;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
/**
* In-memory, volatile token store implementation
@@ -150,6 +151,14 @@ public class InMemoryOAuthTokenStore implements OAuthTokenStore {
}
}
+ @Override
+ public @Nullable String getIdToken(@NotNull ClientConnection connection, @NotNull ResourceResolver resolver)
+ throws OAuthException {
+ Value value = storage.get(new Key(connection.name(), resolver.getUserID()));
+ if (value == null) return null;
+ return value.tokens().idToken();
+ }
+
public Stream<OAuthTokens> allTokens() {
return storage.values().stream().map(Value::tokens);
}
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/impl/InMemoryOAuthTokenStoreTest.java b/src/test/java/org/apache/sling/auth/oauth_client/impl/InMemoryOAuthTokenStoreTest.java
index dab644c..e38eb35 100644
--- a/src/test/java/org/apache/sling/auth/oauth_client/impl/InMemoryOAuthTokenStoreTest.java
+++ b/src/test/java/org/apache/sling/auth/oauth_client/impl/InMemoryOAuthTokenStoreTest.java
@@ -18,14 +18,19 @@
*/
package org.apache.sling.auth.oauth_client.impl;
+import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
+import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
import org.apache.sling.auth.oauth_client.InMemoryOAuthTokenStore;
import org.apache.sling.testing.mock.sling.ResourceResolverType;
import org.apache.sling.testing.mock.sling.junit5.SlingContext;
import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Testcontainers;
+import static org.assertj.core.api.Assertions.assertThat;
+
@Testcontainers
-public class InMemoryOAuthTokenStoreTest extends TokenStoreTestSupport<InMemoryOAuthTokenStore> {
+class InMemoryOAuthTokenStoreTest extends TokenStoreTestSupport<InMemoryOAuthTokenStore> {
InMemoryOAuthTokenStoreTest() {
super(MockOidcConnection.DEFAULT_CONNECTION, new SlingContext(ResourceResolverType.JCR_MOCK));
@@ -36,4 +41,26 @@ public class InMemoryOAuthTokenStoreTest extends TokenStoreTestSupport<InMemoryO
InMemoryOAuthTokenStore createTokenStore() {
return new InMemoryOAuthTokenStore();
}
+
+ @Test
+ void getIdToken_missing() {
+ assertThat(createTokenStore().getIdToken(connection, context.resourceResolver()))
+ .isNull();
+ }
+
+ @Test
+ void getIdToken_persisted() {
+ OIDCTokens tokens = new OIDCTokens("eyJ.id.token", new BearerAccessToken(12), null);
+ InMemoryOAuthTokenStore store = createTokenStore();
+ store.persistTokens(connection, context.resourceResolver(), Converter.toSlingOAuthTokens(tokens));
+ assertThat(store.getIdToken(connection, context.resourceResolver())).isEqualTo("eyJ.id.token");
+ }
+
+ @Test
+ void getIdToken_notStoredWhenNull() {
+ OIDCTokens tokens = new OIDCTokens(new BearerAccessToken(12), null);
+ InMemoryOAuthTokenStore store = createTokenStore();
+ store.persistTokens(connection, context.resourceResolver(), Converter.toSlingOAuthTokens(tokens));
+ assertThat(store.getIdToken(connection, context.resourceResolver())).isNull();
+ }
}
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/impl/JcrUserHomeOAuthTokenStoreTest.java b/src/test/java/org/apache/sling/auth/oauth_client/impl/JcrUserHomeOAuthTokenStoreTest.java
index 0ba6071..fecf715 100644
--- a/src/test/java/org/apache/sling/auth/oauth_client/impl/JcrUserHomeOAuthTokenStoreTest.java
+++ b/src/test/java/org/apache/sling/auth/oauth_client/impl/JcrUserHomeOAuthTokenStoreTest.java
@@ -19,26 +19,56 @@
package org.apache.sling.auth.oauth_client.impl;
import javax.jcr.RepositoryException;
+import javax.jcr.Value;
import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
import org.apache.jackrabbit.api.security.user.User;
import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ValueMap;
import org.apache.sling.auth.oauth_client.ClientConnection;
import org.apache.sling.commons.crypto.CryptoService;
import org.apache.sling.testing.mock.sling.ResourceResolverType;
import org.apache.sling.testing.mock.sling.junit5.SlingContext;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
class JcrUserHomeOAuthTokenStoreTest extends TokenStoreTestSupport<JcrUserHomeOAuthTokenStore> {
private CryptoService cryptoService;
+ private JcrUserHomeOAuthTokenStore tokenStore;
+ private User user;
+ private ResourceResolver mockResolver;
+ private String connectionPath;
@BeforeEach
void init() {
cryptoService = new StubCryptoService();
+ tokenStore = createTokenStore();
+ connectionPath = "oauth-tokens/" + connection.name() + "/id_token";
+
+ user = mock(User.class);
+ mockResolver = mock(ResourceResolver.class);
+ when(mockResolver.adaptTo(User.class)).thenReturn(user);
+ }
+
+ private Value encryptedValue(String plainToken) throws RepositoryException {
+ Value value = mock(Value.class);
+ when(value.getString()).thenReturn(cryptoService.encrypt(plainToken));
+ return value;
+ }
+
+ private void givenTokenAt(String path, String plainToken) throws RepositoryException {
+ Value value = encryptedValue(plainToken); // must be computed before entering outer when()
+ when(user.hasProperty(path)).thenReturn(true);
+ when(user.getProperty(path)).thenReturn(new Value[] {value});
}
JcrUserHomeOAuthTokenStoreTest() {
@@ -79,4 +109,60 @@ class JcrUserHomeOAuthTokenStoreTest extends TokenStoreTestSupport<JcrUserHomeOA
assertThat(connectionResource).as("oauth-tokens/connection resource").isNotNull();
return connectionResource;
}
+
+ // ========== Tests for getIdToken method ==========
+
+ @Test
+ void getIdToken_foundAtConnectionPath() throws RepositoryException {
+ givenTokenAt(connectionPath, "my-id-token");
+ assertEquals("my-id-token", tokenStore.getIdToken(connection, mockResolver));
+ }
+
+ @Test
+ void getIdToken_foundAtProfilePath() throws RepositoryException {
+ givenTokenAt(OAuthTokenStore.PROFILE_PREFIX + OAuthTokenStore.PROPERTY_NAME_ID_TOKEN, "profile-id-token");
+ assertEquals("profile-id-token", tokenStore.getIdToken(connection, mockResolver));
+ }
+
+ @Test
+ void getIdToken_foundAtBarePath() throws RepositoryException {
+ givenTokenAt("id_token", "bare-id-token");
+ assertEquals("bare-id-token", tokenStore.getIdToken(connection, mockResolver));
+ }
+
+ @Test
+ void getIdToken_noTokenAtAnyPath() {
+ assertNull(tokenStore.getIdToken(connection, mockResolver));
+ }
+
+ @Test
+ void getIdToken_emptyOrMissingValue() throws RepositoryException {
+ // empty array
+ when(user.hasProperty(connectionPath)).thenReturn(true);
+ when(user.getProperty(connectionPath)).thenReturn(new Value[0]);
+ assertNull(tokenStore.getIdToken(connection, mockResolver));
+
+ // empty string value
+ Value emptyValue = mock(Value.class);
+ when(emptyValue.getString()).thenReturn("");
+ when(user.getProperty(connectionPath)).thenReturn(new Value[] {emptyValue});
+ assertNull(tokenStore.getIdToken(connection, mockResolver));
+ }
+
+ @Test
+ void getIdToken_decryptionFails() throws RepositoryException {
+ CryptoService failingCrypto = mock(CryptoService.class);
+ when(failingCrypto.decrypt("bad")).thenThrow(new RuntimeException("fail"));
+ Value value = mock(Value.class);
+ when(value.getString()).thenReturn("bad");
+ when(user.hasProperty(connectionPath)).thenReturn(true);
+ when(user.getProperty(connectionPath)).thenReturn(new Value[] {value});
+ assertNull(new JcrUserHomeOAuthTokenStore(failingCrypto).getIdToken(connection, mockResolver));
+ }
+
+ @Test
+ void getIdToken_repositoryException() throws RepositoryException {
+ when(user.hasProperty(connectionPath)).thenThrow(new RepositoryException());
+ assertThrows(OAuthException.class, () -> tokenStore.getIdToken(connection, mockResolver));
+ }
}
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/impl/MockOidcConnection.java b/src/test/java/org/apache/sling/auth/oauth_client/impl/MockOidcConnection.java
index 27ff1cf..8747aa3 100644
--- a/src/test/java/org/apache/sling/auth/oauth_client/impl/MockOidcConnection.java
+++ b/src/test/java/org/apache/sling/auth/oauth_client/impl/MockOidcConnection.java
@@ -60,9 +60,26 @@ public class MockOidcConnection extends OidcConnectionImpl {
String baseUrl,
String[] additionalAuthorizationParameters,
OidcProviderMetadataRegistry metadataRegistry) {
+ this(scopes, name, clientId, clientSecret, baseUrl, additionalAuthorizationParameters, metadataRegistry, null);
+ }
+
+ /**
+ * Creates a mock connection with an optional end_session endpoint for logout tests.
+ *
+ * @param endSessionEndpointUrl full URL of the IdP end_session endpoint, or null for none
+ */
+ public MockOidcConnection(
+ String[] scopes,
+ String name,
+ String clientId,
+ String clientSecret,
+ String baseUrl,
+ String[] additionalAuthorizationParameters,
+ OidcProviderMetadataRegistry metadataRegistry,
+ String endSessionEndpointUrl) {
super(
Converters.standardConverter()
- .convert(Map.ofEntries(
+ .convert(Map.<String, Object>ofEntries(
Map.entry("name", name),
Map.entry("baseUrl", baseUrl),
Map.entry("authorizationEndpoint", ""),
@@ -73,7 +90,10 @@ public class MockOidcConnection extends OidcConnectionImpl {
Map.entry("clientId", clientId),
Map.entry("clientSecret", clientSecret),
Map.entry("scopes", scopes),
- Map.entry("additionalAuthorizationParameters", additionalAuthorizationParameters)))
+ Map.entry("additionalAuthorizationParameters", additionalAuthorizationParameters),
+ Map.entry(
+ "endSessionEndpoint",
+ endSessionEndpointUrl != null ? endSessionEndpointUrl : "")))
.to(Config.class),
metadataRegistry);
}
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/impl/OidcAuthenticationHandlerTest.java b/src/test/java/org/apache/sling/auth/oauth_client/impl/OidcAuthenticationHandlerTest.java
index b07254f..ea99ac0 100644
--- a/src/test/java/org/apache/sling/auth/oauth_client/impl/OidcAuthenticationHandlerTest.java
+++ b/src/test/java/org/apache/sling/auth/oauth_client/impl/OidcAuthenticationHandlerTest.java
@@ -33,6 +33,7 @@ import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
@@ -65,6 +66,9 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.osgi.framework.BundleContext;
import org.osgi.util.converter.Converters;
@@ -72,6 +76,8 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(OsgiContextExtension.class)
@@ -79,6 +85,7 @@ class OidcAuthenticationHandlerTest {
private static final String MOCK_OIDC_PARAM = "mock-oidc-param";
private static final String ISSUER = "myIssuer";
+ private static final String TEST_IDP_END_SESSION_URL = "https://idp.example.com/oidc/logout";
private OsgiContext osgiContext = new OsgiContext();
private BundleContext bundleContext;
private List<ClientConnection> connections;
@@ -91,6 +98,7 @@ class OidcAuthenticationHandlerTest {
private HttpServletResponse response;
private HttpServer tokenEndpointServer;
private CryptoService cryptoService = new StubCryptoService();
+ private OidcLogoutHandler logoutHandler;
HttpServer idpServer;
@BeforeEach
@@ -99,17 +107,27 @@ class OidcAuthenticationHandlerTest {
idpServer = createHttpServer();
bundleContext = osgiContext.bundleContext();
- config = mock(OidcAuthenticationHandler.Config.class);
- when(config.idp()).thenReturn("myIdP");
- when(config.path()).thenReturn(new String[] {"/"});
- when(config.requestKeyCookieMaxAgeSeconds()).thenReturn(300);
+
+ // Create config using Converters pattern
+ Map<String, Object> configMap = new HashMap<>();
+ configMap.put("idp", "myIdP");
+ configMap.put("path", new String[] {"/"});
+ configMap.put("requestKeyCookieMaxAgeSeconds", 300);
+ configMap.put("userInfoEnabled", true);
+ configMap.put("pkceEnabled", false);
+ configMap.put("enableSPInitiatedSingleLogout", false);
+ configMap.put("logoutRedirectPath", "/");
+ configMap.put("logoutRedirectAllowedHosts", new String[] {});
+ config = Converters.standardConverter().convert(configMap).to(OidcAuthenticationHandler.Config.class);
+
loginCookieManager = mock(LoginCookieManager.class);
+ logoutHandler = new OidcLogoutHandler(null);
SlingUserInfoProcessorImpl.Config userInfoConfig = Converters.standardConverter()
.convert(Map.of(
"storeAccessToken", false,
"storeRefreshToken", false,
- "connection", "mock-oidc-param",
+ "connection", MOCK_OIDC_PARAM, // Match MOCK_OIDC_PARAM constant
"groupsInIdToken", false,
"groupsClaimName", "groups"))
.to(SlingUserInfoProcessorImpl.Config.class);
@@ -118,8 +136,6 @@ class OidcAuthenticationHandlerTest {
userInfoProcessors = new ArrayList<>();
userInfoProcessors.add(userInfoProcessor);
- when(config.userInfoEnabled()).thenReturn(true);
- when(config.pkceEnabled()).thenReturn(false);
connections = new ArrayList<>();
connections.add(MockOidcConnection.DEFAULT_CONNECTION);
@@ -251,7 +267,7 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
getOidcProviderMetadataRegistry()));
- when(config.callbackUri()).thenReturn("http://redirect");
+ config = createConfig(Map.of("callbackUri", "http://redirect"));
when(request.getQueryString()).thenReturn("code=authorizationCode&state=part1");
Cookie[] cookies = createMockCookies();
@@ -286,7 +302,7 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
getOidcProviderMetadataRegistry()));
- when(config.callbackUri()).thenReturn("http://redirect");
+ config = createConfig(Map.of("callbackUri", "http://redirect"));
when(request.getQueryString()).thenReturn("code=authorizationCode&state=part1");
Cookie[] cookies = createMockCookies();
@@ -324,7 +340,7 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
getOidcProviderMetadataRegistry("http://jfdljfioewms")));
- when(config.callbackUri()).thenReturn("http://redirect");
+ config = createConfig(Map.of("callbackUri", "http://redirect"));
when(request.getQueryString()).thenReturn("code=authorizationCode&state=part1");
Cookie[] cookies = createMockCookies();
@@ -362,7 +378,7 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
getOidcProviderMetadataRegistry("httjfdljfioewms")));
- when(config.callbackUri()).thenReturn("http://redirect");
+ config = createConfig(Map.of("callbackUri", "http://redirect"));
when(request.getQueryString()).thenReturn("code=authorizationCode&state=part1");
Cookie[] cookies = createMockCookies();
@@ -431,8 +447,9 @@ class OidcAuthenticationHandlerTest {
@Test
void extractCredentials_WithMatchingState_WithValidConnection_WithValidIdToken_WithUserInfo() throws JOSEException {
+ config = createConfig(Map.of("userInfoEnabled", true));
+
RSAKey rsaJWK = new RSAKeyGenerator(2048).keyID("123").generate();
- when(config.userInfoEnabled()).thenReturn(true);
// Test with an id token signed by another key, and expired
AuthenticationInfo authInfo = extractCredentials_WithMatchingState_WithValidConnection_WithIdToken(
createIdToken(rsaJWK, "client-id", ISSUER),
@@ -450,7 +467,7 @@ class OidcAuthenticationHandlerTest {
void extractCredentials_WithMatchingState_WithValidConnection_WithValidIdToken_WithMissingUserInfo()
throws JOSEException {
RSAKey rsaJWK = new RSAKeyGenerator(2048).keyID("123").generate();
- when(config.userInfoEnabled()).thenReturn(true);
+ config = createConfig(Map.of("userInfoEnabled", true));
userInfoProcessors = new ArrayList<>();
createOidcAuthenticationHandler();
@@ -471,7 +488,7 @@ class OidcAuthenticationHandlerTest {
void extractCredentials_WithMatchingState_WithValidConnection_WithValidIdToken_WithUserInfo_WithInvalidNonce()
throws JOSEException {
RSAKey rsaJWK = new RSAKeyGenerator(2048).keyID("123").generate();
- when(config.userInfoEnabled()).thenReturn(true);
+ config = createConfig(Map.of("userInfoEnabled", true));
Cookie stateCookie = mock(Cookie.class);
when(stateCookie.getName()).thenReturn(OAuthCookieValue.COOKIE_NAME_REQUEST_KEY);
@@ -495,8 +512,7 @@ class OidcAuthenticationHandlerTest {
extractCredentials_WithMatchingState_WithValidConnection_WithValidIdToken_WithUserInfo_WithPkceEnabledWithCookie()
throws JOSEException {
RSAKey rsaJWK = new RSAKeyGenerator(2048).keyID("123").generate();
- when(config.userInfoEnabled()).thenReturn(true);
- when(config.pkceEnabled()).thenReturn(true);
+ config = createConfig(Map.of("userInfoEnabled", true, "pkceEnabled", true));
Cookie stateCookie = mock(Cookie.class);
when(stateCookie.getName()).thenReturn(OAuthCookieValue.COOKIE_NAME_REQUEST_KEY);
@@ -522,7 +538,7 @@ class OidcAuthenticationHandlerTest {
void extractCredentials_WithMatchingState_WithValidConnection_WithValidIdToken_WithoutUserInfo()
throws JOSEException {
RSAKey rsaJWK = new RSAKeyGenerator(2048).keyID("123").generate();
- when(config.userInfoEnabled()).thenReturn(false);
+ config = createConfig(Map.of("userInfoEnabled", false));
// Test with an id token signed by another key, and expired
AuthenticationInfo authInfo = extractCredentials_WithMatchingState_WithValidConnection_WithIdToken(
createIdToken(rsaJWK, "client-id", ISSUER),
@@ -539,7 +555,7 @@ class OidcAuthenticationHandlerTest {
void extractCredentials_WithMatchingState_WithValidConnection_WithValidIdToken_WithInvalidUserInfo()
throws JOSEException {
RSAKey rsaJWK = new RSAKeyGenerator(2048).keyID("123").generate();
- when(config.userInfoEnabled()).thenReturn(true);
+ config = createConfig(Map.of("userInfoEnabled", true));
idpServer.createContext("/token", exchange -> {
exchange.getResponseHeaders().add("Content-Type", "application/json");
@@ -574,9 +590,7 @@ class OidcAuthenticationHandlerTest {
idpServer.getAddress().getHostName() + ":"
+ idpServer.getAddress().getPort());
- when(config.callbackUri()).thenReturn("http://redirect");
-
- when(config.callbackUri()).thenReturn("http://redirect");
+ config = createConfig(Map.of("callbackUri", "http://redirect"));
// This is the class used by Sling to configure the Authentication Handler
OidcProviderMetadataRegistry oidcProviderMetadataRegistry = getOidcProviderMetadataRegistry();
@@ -588,7 +602,6 @@ class OidcAuthenticationHandlerTest {
"http://localhost:" + idpServer.getAddress().getPort(),
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.callbackUri()).thenReturn("http://redirect");
when(request.getQueryString()).thenReturn("code=authorizationCode&state=part1");
Cookie[] cookies = createMockCookies();
@@ -628,7 +641,7 @@ class OidcAuthenticationHandlerTest {
void extractCredentials_WithMatchingState_WithValidConnection_WithValidIdToken_WithUnparsableUserInfo()
throws JOSEException {
RSAKey rsaJWK = new RSAKeyGenerator(2048).keyID("123").generate();
- when(config.userInfoEnabled()).thenReturn(true);
+ config = createConfig(Map.of("userInfoEnabled", true));
idpServer.createContext("/token", exchange -> {
exchange.getResponseHeaders().add("Content-Type", "application/json");
@@ -663,9 +676,7 @@ class OidcAuthenticationHandlerTest {
idpServer.getAddress().getHostName() + ":"
+ idpServer.getAddress().getPort());
- when(config.callbackUri()).thenReturn("http://redirect");
-
- when(config.callbackUri()).thenReturn("http://redirect");
+ config = createConfig(Map.of("callbackUri", "http://redirect"));
// This is the class used by Sling to configure the Authentication Handler
OidcProviderMetadataRegistry oidcProviderMetadataRegistry = getOidcProviderMetadataRegistry();
@@ -677,7 +688,6 @@ class OidcAuthenticationHandlerTest {
"http://localhost:" + idpServer.getAddress().getPort(),
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.callbackUri()).thenReturn("http://redirect");
when(request.getQueryString()).thenReturn("code=authorizationCode&state=part1");
Cookie[] cookies = createMockCookies();
@@ -765,8 +775,6 @@ class OidcAuthenticationHandlerTest {
configureWellKnownOidcMetadata(idpServer, rsaJWK, baseUrl);
- when(config.callbackUri()).thenReturn("http://redirect");
-
// This is the class used by Sling to configure the Authentication Handler
OidcProviderMetadataRegistry oidcProviderMetadataRegistry = getOidcProviderMetadataRegistry();
connections.add(new MockOidcConnection(
@@ -777,7 +785,6 @@ class OidcAuthenticationHandlerTest {
"http://localhost:" + idpServer.getAddress().getPort(),
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.callbackUri()).thenReturn("http://redirect");
when(request.getQueryString()).thenReturn("code=authorizationCode&state=part1");
when(request.getCookies()).thenReturn(cookies);
@@ -829,9 +836,41 @@ class OidcAuthenticationHandlerTest {
});
}
+ /**
+ * Creates a config with default values that can be overridden.
+ * Tests can call this to recreate config with specific overrides.
+ */
+ private OidcAuthenticationHandler.Config createConfig(Map<String, Object> overrides) {
+ Map<String, Object> configMap = new HashMap<>();
+ // Default values
+ configMap.put("idp", "myIdP");
+ configMap.put("path", new String[] {"/"});
+ configMap.put("requestKeyCookieMaxAgeSeconds", 300);
+ configMap.put("userInfoEnabled", true);
+ configMap.put("pkceEnabled", false);
+ configMap.put("enableSPInitiatedSingleLogout", false);
+ configMap.put("logoutRedirectPath", "/");
+ configMap.put("logoutRedirectAllowedHosts", new String[] {});
+ configMap.put("callbackUri", "callbackUri");
+ configMap.put("defaultConnectionName", "");
+ configMap.put("resource", new String[] {});
+ configMap.put("logoutServiceUserName", "");
+
+ // Apply overrides
+ configMap.putAll(overrides);
+
+ return Converters.standardConverter().convert(configMap).to(OidcAuthenticationHandler.Config.class);
+ }
+
private void createOidcAuthenticationHandler() {
oidcAuthenticationHandler = new OidcAuthenticationHandler(
- bundleContext, connections, config, loginCookieManager, userInfoProcessors, cryptoService);
+ bundleContext,
+ connections,
+ config,
+ loginCookieManager,
+ userInfoProcessors,
+ cryptoService,
+ logoutHandler);
}
private HttpServer createHttpServer() throws IOException {
@@ -861,10 +900,15 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
- when(config.path()).thenReturn(new String[] {"/"});
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "callbackUri",
+ "http://redirect",
+ "pkceEnabled",
+ false,
+ "path",
+ new String[] {"/"}));
createOidcAuthenticationHandler();
@@ -899,9 +943,8 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
+ config = createConfig(Map.of(
+ "defaultConnectionName", MOCK_OIDC_PARAM, "callbackUri", "http://redirect", "pkceEnabled", false));
when(request.getRequestURI()).thenReturn("/");
MockSlingHttpServletResponse mockResponse = new MockSlingHttpServletResponse();
@@ -953,15 +996,18 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "callbackUri",
+ "http://redirect",
+ "pkceEnabled",
+ true,
+ "path",
+ new String[] {"/"}));
MockSlingHttpServletResponse mockResponse = new MockSlingHttpServletResponse();
- when(config.pkceEnabled()).thenReturn(true);
- when(config.path()).thenReturn(new String[] {"/"});
-
when(request.getRequestURI()).thenReturn("/");
createOidcAuthenticationHandler();
@@ -1027,8 +1073,7 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
+ config = createConfig(Map.of("defaultConnectionName", MOCK_OIDC_PARAM, "callbackUri", "http://redirect"));
when(request.getParameter("c")).thenReturn("unknown-connection");
MockSlingHttpServletResponse response1 = new MockSlingHttpServletResponse();
@@ -1039,10 +1084,186 @@ class OidcAuthenticationHandlerTest {
assertEquals("Client requested unknown connection", response1.getStatusMessage());
}
+ static Stream<Arguments> redirectPathScenarios() {
+ return Stream.of(
+ // logoutRedirectPath, redirectParam, expectedEncodedUri
+ Arguments.of(null, null, "http%3A%2F%2Flocalhost%3A8080%2F"),
+ Arguments.of("/goodbye", null, "http%3A%2F%2Flocalhost%3A8080%2Fgoodbye"),
+ Arguments.of(
+ "/default", "/custom/logout/page", "http%3A%2F%2Flocalhost%3A8080%2Fcustom%2Flogout%2Fpage"),
+ Arguments.of("/default", "https://evil.com/malicious", "http%3A%2F%2Flocalhost%3A8080%2Fdefault"),
+ Arguments.of("/default", "//evil.com/malicious", "http%3A%2F%2Flocalhost%3A8080%2Fdefault"),
+ Arguments.of("/default", "", "http%3A%2F%2Flocalhost%3A8080%2Fdefault"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("redirectPathScenarios")
+ void dropCredentials_redirectPath(String logoutRedirectPath, String redirectParam, String expectedEncodedUri)
+ throws IOException {
+ setupConnectionWithLogout(TEST_IDP_END_SESSION_URL);
+ Map<String, Object> configOverrides = new HashMap<>(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "enableSPInitiatedSingleLogout",
+ true,
+ "logoutRedirectAllowedHosts",
+ new String[] {"localhost"}));
+ if (logoutRedirectPath != null) {
+ configOverrides.put("logoutRedirectPath", logoutRedirectPath);
+ }
+ config = createConfig(configOverrides);
+ setupLogoutRequest("http", "localhost", 8080, redirectParam);
+
+ createOidcAuthenticationHandler();
+ oidcAuthenticationHandler.dropCredentials(request, response);
+
+ verifyIdpLogoutRedirect(TEST_IDP_END_SESSION_URL, expectedEncodedUri);
+ }
+
+ @Test
+ void dropCredentials_doesNotRedirectWhenResponseAlreadyCommitted() throws IOException {
+ connections.clear();
+ connections.add(new MockOidcConnection(
+ new String[] {"openid"},
+ MOCK_OIDC_PARAM,
+ "client-id",
+ "client-secret",
+ "https://idp.example.com",
+ new String[0],
+ null,
+ TEST_IDP_END_SESSION_URL));
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "enableSPInitiatedSingleLogout",
+ true,
+ "logoutRedirectAllowedHosts",
+ new String[] {"localhost"}));
+ setupLogoutRequest("http", "localhost", 8080, null);
+ when(response.isCommitted()).thenReturn(true);
+
+ createOidcAuthenticationHandler();
+ oidcAuthenticationHandler.dropCredentials(request, response);
+
+ verify(loginCookieManager).clearLoginCookie(request, response);
+ verify(response, never()).sendRedirect(anyString());
+ }
+
+ // Distinct from dropCredentials_redirectPath: here the spoofing is in the HTTP Host header
+ // (request server name is evil.com, not in allowed hosts), so the redirect host must be
+ // replaced with one from logoutRedirectAllowedHosts. dropCredentials_redirectPath only tests
+ // a malicious redirect *parameter* while the request host is already trusted.
+ @Test
+ void dropCredentials_allowedHostsReplacesSpoofedHost() throws IOException {
+ setupConnectionWithLogout(TEST_IDP_END_SESSION_URL);
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "enableSPInitiatedSingleLogout",
+ true,
+ "logoutRedirectAllowedHosts",
+ new String[] {"safe.example.com", "other.example.com"}));
+ setupLogoutRequest("https", "evil.com", 443, null);
+
+ createOidcAuthenticationHandler();
+ oidcAuthenticationHandler.dropCredentials(request, response);
+
+ org.mockito.ArgumentCaptor<String> redirectCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(response).sendRedirect(redirectCaptor.capture());
+ String redirectUrl = redirectCaptor.getValue();
+ assertTrue(redirectUrl.startsWith(TEST_IDP_END_SESSION_URL), "Redirect should go to IdP end_session");
+ assertTrue(redirectUrl.contains("post_logout_redirect_uri="), "Should contain post_logout_redirect_uri");
+ assertTrue(
+ redirectUrl.contains("safe.example.com") || redirectUrl.contains("other.example.com"),
+ "Should use allowed host instead of spoofed");
+ assertFalse(redirectUrl.contains("evil.com"), "Must not use spoofed host");
+ }
+
+ // Happy path for host validation: request host (localhost) is in logoutRedirectAllowedHosts,
+ // so the redirect preserves it as-is. Complements dropCredentials_allowedHostsReplacesSpoofedHost
+ // which covers the case where the request host is not trusted and must be replaced.
+ @Test
+ void dropCredentials_allowedHostsAllowsConfiguredHost() throws IOException {
+ setupConnectionWithLogout(TEST_IDP_END_SESSION_URL);
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "enableSPInitiatedSingleLogout",
+ true,
+ "logoutRedirectAllowedHosts",
+ new String[] {"localhost", "myapp.example.com"}));
+ setupLogoutRequest("http", "localhost", 8080, null);
+
+ createOidcAuthenticationHandler();
+ oidcAuthenticationHandler.dropCredentials(request, response);
+
+ verifyIdpLogoutRedirect(TEST_IDP_END_SESSION_URL, "http%3A%2F%2Flocalhost%3A8080%2F");
+ }
+
+ @Test
+ void dropCredentials_loginCookieManagerNull_doesNotThrowAndRedirects() throws IOException {
+ loginCookieManager = null;
+ setupConnectionWithLogout(TEST_IDP_END_SESSION_URL);
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "enableSPInitiatedSingleLogout",
+ true,
+ "logoutRedirectAllowedHosts",
+ new String[] {"localhost"}));
+ setupLogoutRequest("http", "localhost", 8080, null);
+
+ createOidcAuthenticationHandler();
+ oidcAuthenticationHandler.dropCredentials(request, response);
+
+ verifyIdpLogoutRedirect(TEST_IDP_END_SESSION_URL, "http%3A%2F%2Flocalhost%3A8080%2F");
+ }
+
+ @Test
+ void dropCredentials_withRelativeRedirectParameter_usesParamAsIs() throws IOException {
+ setupConnectionWithLogout(TEST_IDP_END_SESSION_URL);
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "enableSPInitiatedSingleLogout",
+ true,
+ "logoutRedirectAllowedHosts",
+ new String[] {"localhost"},
+ "logoutRedirectPath",
+ "/default"));
+ setupLogoutRequest("http", "localhost", 8080, "/custom/logout"); // Sling always deploys at root
+
+ createOidcAuthenticationHandler();
+ oidcAuthenticationHandler.dropCredentials(request, response);
+
+ verifyIdpLogoutRedirect(TEST_IDP_END_SESSION_URL, "http%3A%2F%2Flocalhost%3A8080%2Fcustom%2Flogout");
+ }
+
@Test
- void dropCredentials() {
- // TODO this test doesn't verify anything
+ void dropCredentials_withRelativeRedirectParameter_disallowedRequestHost_usesSafeHost() throws IOException {
+ setupConnectionWithLogout(TEST_IDP_END_SESSION_URL);
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "enableSPInitiatedSingleLogout",
+ true,
+ "logoutRedirectAllowedHosts",
+ new String[] {"safe.example.com"},
+ "logoutRedirectPath",
+ "/default"));
+ setupLogoutRequest("https", "evil.com", 443, "/custom/logout");
+
+ createOidcAuthenticationHandler();
oidcAuthenticationHandler.dropCredentials(request, response);
+
+ // Relative redirect with spoofed host should use safe host
+ org.mockito.ArgumentCaptor<String> redirectCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(response).sendRedirect(redirectCaptor.capture());
+ String redirectUrl = redirectCaptor.getValue();
+ assertTrue(redirectUrl.startsWith(TEST_IDP_END_SESSION_URL), "Should go to IdP end_session");
+ assertTrue(redirectUrl.contains("post_logout_redirect_uri="), "Should contain post_logout_redirect_uri");
+ assertTrue(redirectUrl.contains("safe.example.com"), "Should use safe host instead of spoofed");
+ assertTrue(redirectUrl.contains("%2Fcustom%2Flogout"), "Should use custom logout path");
}
@Test
@@ -1076,7 +1297,7 @@ class OidcAuthenticationHandlerTest {
MockSlingHttpServletRequest mockRequest = new MockSlingHttpServletRequest(bundleContext);
mockRequest.setAttribute(OidcAuthenticationHandler.REDIRECT_ATTRIBUTE_NAME, "http://localhost:8080");
- when(config.pkceEnabled()).thenReturn(true);
+ config = createConfig(Map.of("pkceEnabled", true));
createOidcAuthenticationHandler();
assertTrue(oidcAuthenticationHandler.authenticationSucceeded(mockRequest, mockResponse, authInfo));
@@ -1135,10 +1356,15 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
- when(config.path()).thenReturn(new String[] {"/"});
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "callbackUri",
+ "http://redirect",
+ "pkceEnabled",
+ false,
+ "path",
+ new String[] {"/"}));
// Test with oidc_request_path parameter
when(request.getParameter("c")).thenReturn(MOCK_OIDC_PARAM);
@@ -1180,10 +1406,15 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
- when(config.path()).thenReturn(new String[] {"/"});
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "callbackUri",
+ "http://redirect",
+ "pkceEnabled",
+ false,
+ "path",
+ new String[] {"/"}));
// Test with invalid oidc_request_path parameter (absolute URL)
when(request.getParameter("c")).thenReturn(MOCK_OIDC_PARAM);
@@ -1220,10 +1451,15 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
- when(config.path()).thenReturn(new String[] {"/"});
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "callbackUri",
+ "http://redirect",
+ "pkceEnabled",
+ false,
+ "path",
+ new String[] {"/"}));
// Test with null oidc_request_path parameter - should fall back to requestURI
when(request.getParameter("c")).thenReturn(MOCK_OIDC_PARAM);
@@ -1265,10 +1501,15 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
- when(config.path()).thenReturn(new String[] {"/"});
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "callbackUri",
+ "http://redirect",
+ "pkceEnabled",
+ false,
+ "path",
+ new String[] {"/"}));
// Test with null oidc_request_path parameter - should fall back to requestURI
when(request.getParameter("c")).thenReturn(MOCK_OIDC_PARAM);
@@ -1311,11 +1552,17 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
- when(config.path()).thenReturn(new String[] {"/"});
- when(config.resource()).thenReturn(new String[] {"https://api.example.com"});
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "callbackUri",
+ "http://redirect",
+ "pkceEnabled",
+ false,
+ "path",
+ new String[] {"/"},
+ "resource",
+ new String[] {"https://api.example.com"}));
when(request.getRequestURI()).thenReturn("/");
MockSlingHttpServletResponse mockResponse = new MockSlingHttpServletResponse();
@@ -1351,11 +1598,17 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
- when(config.path()).thenReturn(new String[] {"/"});
- when(config.resource()).thenReturn(new String[] {"https://api1.example.com", "https://api2.example.com"});
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "callbackUri",
+ "http://redirect",
+ "pkceEnabled",
+ false,
+ "path",
+ new String[] {"/"},
+ "resource",
+ new String[] {"https://api1.example.com", "https://api2.example.com"}));
when(request.getRequestURI()).thenReturn("/");
MockSlingHttpServletResponse mockResponse = new MockSlingHttpServletResponse();
@@ -1394,11 +1647,17 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
- when(config.path()).thenReturn(new String[] {"/"});
- when(config.resource()).thenReturn(new String[] {});
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "callbackUri",
+ "http://redirect",
+ "pkceEnabled",
+ false,
+ "path",
+ new String[] {"/"},
+ "resource",
+ new String[] {}));
when(request.getRequestURI()).thenReturn("/");
MockSlingHttpServletResponse mockResponse = new MockSlingHttpServletResponse();
@@ -1414,7 +1673,7 @@ class OidcAuthenticationHandlerTest {
}
@Test
- void requestCredentialsWithNullResourceAttribute() {
+ void requestCredentialsWithResourceAttributeContainingEmptyStrings() {
// This is the class used by Sling to configure the Authentication Handler
OidcProviderMetadataRegistry oidcProviderMetadataRegistry = mock(OidcProviderMetadataRegistry.class);
String mockIdPUrl = "http://localhost:8080";
@@ -1433,11 +1692,18 @@ class OidcAuthenticationHandlerTest {
new String[] {"access_type=offline"},
oidcProviderMetadataRegistry));
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
- when(config.path()).thenReturn(new String[] {"/"});
- when(config.resource()).thenReturn(null);
+ // Array with empty strings and whitespace should be filtered out
+ config = createConfig(Map.of(
+ "defaultConnectionName",
+ MOCK_OIDC_PARAM,
+ "callbackUri",
+ "http://redirect",
+ "pkceEnabled",
+ false,
+ "path",
+ new String[] {"/"},
+ "resource",
+ new String[] {"", " ", "https://api.example.com"}));
when(request.getRequestURI()).thenReturn("/");
MockSlingHttpServletResponse mockResponse = new MockSlingHttpServletResponse();
@@ -1445,54 +1711,72 @@ class OidcAuthenticationHandlerTest {
createOidcAuthenticationHandler();
assertTrue(oidcAuthenticationHandler.requestCredentials(request, mockResponse));
- // Verify that no resource parameter is present in the redirect URL
+ // Verify that only the valid resource parameter is present in the redirect URL
assertEquals(302, mockResponse.getStatus());
String location = mockResponse.getHeader("location");
- assertFalse(
- location.contains("resource="), "Expected no resource parameter in redirect URL but got: " + location);
+ assertTrue(
+ location.contains("resource=https%3A%2F%2Fapi.example.com"),
+ "Expected valid resource parameter in redirect URL but got: " + location);
+ // Count occurrences of "resource=" - should be exactly 1
+ int count = location.split("resource=", -1).length - 1;
+ assertEquals(1, count, "Expected exactly one resource parameter but found " + count);
}
- @Test
- void requestCredentialsWithResourceAttributeContainingEmptyStrings() {
- // This is the class used by Sling to configure the Authentication Handler
- OidcProviderMetadataRegistry oidcProviderMetadataRegistry = mock(OidcProviderMetadataRegistry.class);
- String mockIdPUrl = "http://localhost:8080";
- when(oidcProviderMetadataRegistry.getJWKSetURI(mockIdPUrl)).thenReturn(URI.create(mockIdPUrl + "/jwks.json"));
- when(oidcProviderMetadataRegistry.getIssuer(mockIdPUrl)).thenReturn(ISSUER);
- when(oidcProviderMetadataRegistry.getAuthorizationEndpoint(mockIdPUrl))
- .thenReturn(URI.create(mockIdPUrl + "/authorize"));
- when(oidcProviderMetadataRegistry.getTokenEndpoint(mockIdPUrl)).thenReturn(URI.create(mockIdPUrl + "/token"));
+ // ============================================
+ // Helper methods to reduce test duplication
+ // ============================================
+ /**
+ * Sets up a connection with logout endpoint for testing SP-initiated logout.
+ * Clears existing connections and adds a new MockOidcConnection with the provided endSessionUrl.
+ */
+ private void setupConnectionWithLogout(String endSessionUrl) {
+ connections.clear();
connections.add(new MockOidcConnection(
new String[] {"openid"},
MOCK_OIDC_PARAM,
"client-id",
"client-secret",
- "http://localhost:8080",
- new String[] {"access_type=offline"},
- oidcProviderMetadataRegistry));
-
- when(config.defaultConnectionName()).thenReturn(MOCK_OIDC_PARAM);
- when(config.callbackUri()).thenReturn("http://redirect");
- when(config.pkceEnabled()).thenReturn(false);
- when(config.path()).thenReturn(new String[] {"/"});
- // Array with empty strings and whitespace should be filtered out
- when(config.resource()).thenReturn(new String[] {"", " ", "https://api.example.com"});
-
- when(request.getRequestURI()).thenReturn("/");
- MockSlingHttpServletResponse mockResponse = new MockSlingHttpServletResponse();
+ "https://idp.example.com",
+ new String[0],
+ null,
+ endSessionUrl));
+ }
- createOidcAuthenticationHandler();
- assertTrue(oidcAuthenticationHandler.requestCredentials(request, mockResponse));
+ /**
+ * Sets up common request mocks for logout tests.
+ * @param scheme HTTP scheme (http or https)
+ * @param host Server hostname
+ * @param port Server port
+ * @param redirectParam Optional redirect parameter (can be null)
+ */
+ private void setupLogoutRequest(String scheme, String host, int port, String redirectParam) {
+ when(request.getScheme()).thenReturn(scheme);
+ when(request.getServerName()).thenReturn(host);
+ when(request.getServerPort()).thenReturn(port);
+ when(request.getContextPath()).thenReturn("");
+ if (redirectParam != null) {
+ when(request.getParameter(RedirectHelper.PARAMETER_NAME_REDIRECT)).thenReturn(redirectParam);
+ }
+ }
- // Verify that only the valid resource parameter is present in the redirect URL
- assertEquals(302, mockResponse.getStatus());
- String location = mockResponse.getHeader("location");
+ /**
+ * Verifies that the response redirects to the IdP end_session_endpoint with the expected post_logout_redirect_uri.
+ * @param expectedEndSessionUrl Expected IdP end_session endpoint URL
+ * @param expectedPostLogoutUriFragment Expected fragment in the post_logout_redirect_uri parameter (URL-encoded)
+ */
+ private void verifyIdpLogoutRedirect(String expectedEndSessionUrl, String expectedPostLogoutUriFragment)
+ throws IOException {
+ org.mockito.ArgumentCaptor<String> redirectCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(response).sendRedirect(redirectCaptor.capture());
+ String redirectUrl = redirectCaptor.getValue();
assertTrue(
- location.contains("resource=https%3A%2F%2Fapi.example.com"),
- "Expected valid resource parameter in redirect URL but got: " + location);
- // Count occurrences of "resource=" - should be exactly 1
- int count = location.split("resource=", -1).length - 1;
- assertEquals(1, count, "Expected exactly one resource parameter but found " + count);
+ redirectUrl.startsWith(expectedEndSessionUrl), "Redirect should go to IdP end_session: " + redirectUrl);
+ assertTrue(
+ redirectUrl.contains("post_logout_redirect_uri="),
+ "Redirect should contain post_logout_redirect_uri: " + redirectUrl);
+ assertTrue(
+ redirectUrl.contains(expectedPostLogoutUriFragment),
+ "post_logout_redirect_uri should contain expected fragment: " + redirectUrl);
}
}
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/impl/OidcLogoutHandlerTest.java b/src/test/java/org/apache/sling/auth/oauth_client/impl/OidcLogoutHandlerTest.java
new file mode 100644
index 0000000..07e758a
--- /dev/null
+++ b/src/test/java/org/apache/sling/auth/oauth_client/impl/OidcLogoutHandlerTest.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.sling.auth.oauth_client.impl;
+
+import java.net.URI;
+import java.util.Map;
+
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.auth.oauth_client.ClientConnection;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OidcLogoutHandlerTest {
+
+ private static final ClientConnection DEFAULT = MockOidcConnection.DEFAULT_CONNECTION;
+ private static final URI LOGOUT_ENDPOINT = URI.create("https://idp.example.com/logout");
+
+ // ========== getIdTokenFromTokenStore ==========
+
+ @Test
+ void getIdTokenFromTokenStore_returnsNullForMissingParams() {
+ OAuthTokenStore store = mock(OAuthTokenStore.class);
+ assertNull(new OidcLogoutHandler(store).getIdTokenFromTokenStore(null, DEFAULT));
+ assertNull(new OidcLogoutHandler(store).getIdTokenFromTokenStore(mock(ResourceResolver.class), null));
+ assertNull(new OidcLogoutHandler(null).getIdTokenFromTokenStore(mock(ResourceResolver.class), DEFAULT));
+ }
+
+ @Test
+ void getIdTokenFromTokenStore_returnsToken() {
+ ResourceResolver resolver = mock(ResourceResolver.class);
+ OAuthTokenStore store = mock(OAuthTokenStore.class);
+ when(store.getIdToken(DEFAULT, resolver)).thenReturn("stored-id-token");
+ assertEquals("stored-id-token", new OidcLogoutHandler(store).getIdTokenFromTokenStore(resolver, DEFAULT));
+ }
+
+ @Test
+ void getIdTokenFromTokenStore_oauthException_returnsNull() {
+ ResourceResolver resolver = mock(ResourceResolver.class);
+ OAuthTokenStore store = mock(OAuthTokenStore.class);
+ when(store.getIdToken(DEFAULT, resolver)).thenThrow(new OAuthException("revoked"));
+ assertNull(new OidcLogoutHandler(store).getIdTokenFromTokenStore(resolver, DEFAULT));
+ }
+
+ // ========== buildLogoutUrl ==========
+
+ @Test
+ void buildLogoutUrl_withIdTokenHint() {
+ String result = OidcLogoutHandler.buildLogoutUrl(LOGOUT_ENDPOINT, "https://app.example.com/out", "token");
+ assertTrue(result.startsWith("https://idp.example.com/logout?"));
+ assertTrue(result.contains("id_token_hint="));
+ assertTrue(result.contains("post_logout_redirect_uri="));
+ }
+
+ @Test
+ void buildLogoutUrl_nullOrEmptyHint_omitsHint() {
+ String withNull = OidcLogoutHandler.buildLogoutUrl(LOGOUT_ENDPOINT, "https://app.example.com/out", null);
+ String withEmpty = OidcLogoutHandler.buildLogoutUrl(LOGOUT_ENDPOINT, "https://app.example.com/out", "");
+ assertFalse(withNull.contains("id_token_hint="));
+ assertFalse(withEmpty.contains("id_token_hint="));
+ }
+
+ @Test
+ void buildLogoutUrl_urlEncoding() {
+ String result = OidcLogoutHandler.buildLogoutUrl(
+ LOGOUT_ENDPOINT, "https://app.example.com/out?x=y with spaces", "token with spaces");
+ assertTrue(result.contains("id_token_hint=token+with+spaces"));
+ assertTrue(result.contains("post_logout_redirect_uri=https%3A%2F%2Fapp.example.com"));
+ }
+
+ // ========== resolveConnectionForLogout ==========
+
+ @Test
+ void resolveConnectionForLogout_emptyConnections() {
+ assertNull(OidcLogoutHandler.resolveConnectionForLogout(Map.of(), "default"));
+ }
+
+ @Test
+ void resolveConnectionForLogout_unknownDefault() {
+ assertNull(OidcLogoutHandler.resolveConnectionForLogout(Map.of(DEFAULT.name(), DEFAULT), "unknown"));
+ }
+
+ @Test
+ void resolveConnectionForLogout_emptyDefault_returnsFirst() {
+ assertEquals(DEFAULT, OidcLogoutHandler.resolveConnectionForLogout(Map.of(DEFAULT.name(), DEFAULT), ""));
+ }
+
+ // ========== getEndSessionEndpoint ==========
+
+ @Test
+ void getEndSessionEndpoint_nonOidcConnection() {
+ assertNull(new OidcLogoutHandler(null).getEndSessionEndpoint(mock(OAuthConnectionImpl.class)));
+ }
+
+ @Test
+ void getEndSessionEndpoint_oidcConnection() throws Exception {
+ MockOidcConnection conn = new MockOidcConnection(
+ new String[] {"openid"},
+ "param",
+ "client-id",
+ "secret",
+ "https://idp.example.com",
+ new String[0],
+ null,
+ "https://idp.example.com/logout");
+ assertEquals(
+ new URI("https://idp.example.com/logout"), new OidcLogoutHandler(null).getEndSessionEndpoint(conn));
+ }
+
+ // ========== UriBuilder ==========
+
+ @Test
+ void uriBuilder_invalidInputThrows() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> OidcLogoutHandler.UriBuilder.buildRedirectUri("", "host", 80, "/"));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> OidcLogoutHandler.UriBuilder.buildRedirectUri("http", "", 80, "/"));
+ }
+
+ @Test
+ void uriBuilder_defaultPortsOmittedAndNullPathDefaultsToRoot() {
+ assertFalse(OidcLogoutHandler.UriBuilder.buildRedirectUri("http", "localhost", 80, "/")
+ .contains(":80"));
+ assertFalse(OidcLogoutHandler.UriBuilder.buildRedirectUri("https", "host", 443, "/")
+ .contains(":443"));
+ assertTrue(OidcLogoutHandler.UriBuilder.buildRedirectUri("http", "localhost", 8080, null)
+ .endsWith("/"));
+ }
+}
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/impl/RedisOAuthTokenStoreTest.java b/src/test/java/org/apache/sling/auth/oauth_client/impl/RedisOAuthTokenStoreTest.java
index 86966d6..101f752 100644
--- a/src/test/java/org/apache/sling/auth/oauth_client/impl/RedisOAuthTokenStoreTest.java
+++ b/src/test/java/org/apache/sling/auth/oauth_client/impl/RedisOAuthTokenStoreTest.java
@@ -20,18 +20,23 @@ package org.apache.sling.auth.oauth_client.impl;
import java.util.Map;
+import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
+import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
import com.redis.testcontainers.RedisContainer;
import org.apache.sling.auth.oauth_client.impl.RedisOAuthTokenStore.Config;
import org.apache.sling.testing.mock.sling.ResourceResolverType;
import org.apache.sling.testing.mock.sling.junit5.SlingContext;
import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Test;
import org.osgi.util.converter.Converters;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
+import static org.assertj.core.api.Assertions.assertThat;
+
@Testcontainers
-public class RedisOAuthTokenStoreTest extends TokenStoreTestSupport<RedisOAuthTokenStore> {
+class RedisOAuthTokenStoreTest extends TokenStoreTestSupport<RedisOAuthTokenStore> {
@Container
private final RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:6.2.6"));
@@ -49,4 +54,26 @@ public class RedisOAuthTokenStoreTest extends TokenStoreTestSupport<RedisOAuthTo
return new RedisOAuthTokenStore(cfg);
}
+
+ @Test
+ void getIdToken_missing() {
+ assertThat(createTokenStore().getIdToken(connection, context.resourceResolver()))
+ .isNull();
+ }
+
+ @Test
+ void getIdToken_persisted() {
+ OIDCTokens tokens = new OIDCTokens("eyJ.id.token", new BearerAccessToken(12), null);
+ RedisOAuthTokenStore store = createTokenStore();
+ store.persistTokens(connection, context.resourceResolver(), Converter.toSlingOAuthTokens(tokens));
+ assertThat(store.getIdToken(connection, context.resourceResolver())).isEqualTo("eyJ.id.token");
+ }
+
+ @Test
+ void getIdToken_notStoredWhenNull() {
+ OIDCTokens tokens = new OIDCTokens(new BearerAccessToken(12), null);
+ RedisOAuthTokenStore store = createTokenStore();
+ store.persistTokens(connection, context.resourceResolver(), Converter.toSlingOAuthTokens(tokens));
+ assertThat(store.getIdToken(connection, context.resourceResolver())).isNull();
+ }
}
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingLoginCookieManagerTest.java b/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingLoginCookieManagerTest.java
index 7836b45..16b0a15 100644
--- a/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingLoginCookieManagerTest.java
+++ b/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingLoginCookieManagerTest.java
@@ -70,6 +70,7 @@ class SlingLoginCookieManagerTest {
when(config.form_token_fastseed()).thenReturn(false);
when(config.sessionTimeout()).thenReturn(8 * 60 * 60 * 1000L);
when(config.cookieName()).thenReturn(COOKIE_NAME);
+ when(config.idpName()).thenReturn("test-idp");
slingLoginCookieManager = new SlingLoginCookieManager(config, osgiContext.bundleContext());
}
diff --git a/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImplTest.java b/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImplTest.java
index cb45f4d..5076515 100644
--- a/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImplTest.java
+++ b/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImplTest.java
@@ -351,4 +351,188 @@ class SlingUserInfoProcessorImplTest {
groups.forEach(groupList::add);
assertTrue(groupList.isEmpty(), "Expected no groups");
}
+
+ // ========== Tests for ID token storage (storeIdToken) ==========
+
+ @Test
+ void testProcess_StoreIdToken_Success() throws Exception {
+ // Setup crypto service mock
+ when(cryptoService.encrypt(anyString())).thenReturn("encrypted-id-token");
+
+ // Setup config with storeIdToken enabled
+ SlingUserInfoProcessorImpl.Config cfg = Converters.standardConverter()
+ .convert(Map.of(
+ "groupsInIdToken", false,
+ "storeAccessToken", false,
+ "storeRefreshToken", false,
+ "storeIdToken", true,
+ "groupsClaimName", "groups",
+ "connection", "test"))
+ .to(SlingUserInfoProcessorImpl.Config.class);
+
+ SlingUserInfoProcessorImpl testProcessor = new SlingUserInfoProcessorImpl(cryptoService, cfg);
+
+ // Create token response with ID token
+ String idToken = createIdToken(TEST_SUBJECT);
+ String tokenResponse = createTokenResponseWithIdToken(TEST_ACCESS_TOKEN, TEST_REFRESH_TOKEN, idToken);
+
+ // Execute
+ OidcAuthCredentials result = testProcessor.process(null, tokenResponse, TEST_SUBJECT, TEST_IDP);
+
+ // Verify ID token was encrypted and stored
+ assertNotNull(result);
+ assertTrue(result.getAttributes().containsKey(OAuthTokenStore.PROPERTY_NAME_ID_TOKEN));
+ String storedToken = (String) result.getAttribute(OAuthTokenStore.PROPERTY_NAME_ID_TOKEN);
+ assertNotNull(storedToken);
+ assertEquals("encrypted-id-token", storedToken);
+ verify(cryptoService).encrypt(anyString());
+ }
+
+ @Test
+ void testProcess_StoreIdToken_NullIdToken() throws Exception {
+ // Setup config with storeIdToken enabled
+ SlingUserInfoProcessorImpl.Config cfg = Converters.standardConverter()
+ .convert(Map.of(
+ "groupsInIdToken", false,
+ "storeAccessToken", false,
+ "storeRefreshToken", false,
+ "storeIdToken", true,
+ "groupsClaimName", "groups",
+ "connection", "test"))
+ .to(SlingUserInfoProcessorImpl.Config.class);
+
+ // Create mock crypto that can handle nulls
+ CryptoService mockCrypto = mock(CryptoService.class);
+ when(mockCrypto.encrypt(anyString())).thenAnswer(inv -> "encrypted-" + inv.getArgument(0));
+
+ SlingUserInfoProcessorImpl testProcessor = new SlingUserInfoProcessorImpl(mockCrypto, cfg);
+
+ // Create token response with null ID token (by using non-OIDC response)
+ String tokenResponse = createTokenResponse(TEST_ACCESS_TOKEN, TEST_REFRESH_TOKEN);
+
+ // Execute - should not throw exception
+ OidcAuthCredentials result = testProcessor.process(null, tokenResponse, TEST_SUBJECT, TEST_IDP);
+
+ // Verify result is returned but no ID token stored
+ assertNotNull(result);
+ assertFalse(result.getAttributes().containsKey(OAuthTokenStore.PROPERTY_NAME_ID_TOKEN));
+ }
+
+ @Test
+ void testProcess_StoreIdToken_EmptyIdToken() {
+ // Setup config with storeIdToken enabled
+ SlingUserInfoProcessorImpl.Config cfg = Converters.standardConverter()
+ .convert(Map.of(
+ "groupsInIdToken", false,
+ "storeAccessToken", false,
+ "storeRefreshToken", false,
+ "storeIdToken", true,
+ "groupsClaimName", "groups",
+ "connection", "test"))
+ .to(SlingUserInfoProcessorImpl.Config.class);
+
+ SlingUserInfoProcessorImpl testProcessor = new SlingUserInfoProcessorImpl(cryptoService, cfg);
+
+ // Create token response with empty ID token
+ String tokenResponse = createTokenResponseWithIdToken(TEST_ACCESS_TOKEN, TEST_REFRESH_TOKEN, "");
+
+ // Execute - should handle gracefully
+ OidcAuthCredentials result = testProcessor.process(null, tokenResponse, TEST_SUBJECT, TEST_IDP);
+
+ // Verify result is returned but no ID token stored
+ assertNotNull(result);
+ assertFalse(result.getAttributes().containsKey(OAuthTokenStore.PROPERTY_NAME_ID_TOKEN));
+ }
+
+ @Test
+ void testProcess_StoreIdToken_EncryptionException() throws Exception {
+ // Setup config with storeIdToken enabled
+ SlingUserInfoProcessorImpl.Config cfg = Converters.standardConverter()
+ .convert(Map.of(
+ "groupsInIdToken", false,
+ "storeAccessToken", false,
+ "storeRefreshToken", false,
+ "storeIdToken", true,
+ "groupsClaimName", "groups",
+ "connection", "test"))
+ .to(SlingUserInfoProcessorImpl.Config.class);
+
+ // Create mock crypto that throws RuntimeException
+ CryptoService mockCrypto = mock(CryptoService.class);
+ when(mockCrypto.encrypt(anyString())).thenThrow(new RuntimeException("Encryption failed"));
+
+ SlingUserInfoProcessorImpl testProcessor = new SlingUserInfoProcessorImpl(mockCrypto, cfg);
+
+ // Create token response with ID token
+ String idToken = createIdToken(TEST_SUBJECT);
+ String tokenResponse = createTokenResponseWithIdToken(TEST_ACCESS_TOKEN, TEST_REFRESH_TOKEN, idToken);
+
+ // Execute - should handle exception gracefully
+ OidcAuthCredentials result = testProcessor.process(null, tokenResponse, TEST_SUBJECT, TEST_IDP);
+
+ // Verify result is returned but no ID token stored (encryption failed)
+ assertNotNull(result);
+ assertFalse(result.getAttributes().containsKey(OAuthTokenStore.PROPERTY_NAME_ID_TOKEN));
+ }
+
+ @Test
+ void testProcess_StoreIdToken_Disabled() throws Exception {
+ // Setup config with storeIdToken disabled
+ SlingUserInfoProcessorImpl.Config cfg = Converters.standardConverter()
+ .convert(Map.of(
+ "groupsInIdToken", false,
+ "storeAccessToken", false,
+ "storeRefreshToken", false,
+ "storeIdToken", false, // disabled
+ "groupsClaimName", "groups",
+ "connection", "test"))
+ .to(SlingUserInfoProcessorImpl.Config.class);
+
+ SlingUserInfoProcessorImpl testProcessor = new SlingUserInfoProcessorImpl(cryptoService, cfg);
+
+ // Create token response with ID token
+ String idToken = createIdToken(TEST_SUBJECT);
+ String tokenResponse = createTokenResponseWithIdToken(TEST_ACCESS_TOKEN, TEST_REFRESH_TOKEN, idToken);
+
+ // Execute
+ OidcAuthCredentials result = testProcessor.process(null, tokenResponse, TEST_SUBJECT, TEST_IDP);
+
+ // Verify ID token was NOT stored (disabled)
+ assertNotNull(result);
+ assertFalse(result.getAttributes().containsKey(OAuthTokenStore.PROPERTY_NAME_ID_TOKEN));
+ }
+
+ // Helper methods for ID token tests
+
+ private String createIdToken(String subject) throws Exception {
+ // Create a simple signed JWT for testing
+ JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
+ .subject(subject)
+ .issuer("https://test-issuer.example.com")
+ .audience("test-client-id")
+ .expirationTime(new java.util.Date(System.currentTimeMillis() + 3600000))
+ .issueTime(new java.util.Date())
+ .build();
+
+ // Create signed JWT
+ SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet);
+
+ // Sign with a test secret
+ byte[] secret = "test-secret-key-for-signing-must-be-long".getBytes();
+ signedJWT.sign(new com.nimbusds.jose.crypto.MACSigner(secret));
+
+ return signedJWT.serialize();
+ }
+
+ private String createTokenResponseWithIdToken(String accessToken, String refreshToken, String idToken) {
+ JSONObject json = new JSONObject();
+ json.put("access_token", accessToken);
+ json.put("refresh_token", refreshToken);
+ json.put("token_type", "Bearer");
+ json.put("expires_in", 3600);
+ if (idToken != null && !idToken.isEmpty()) {
+ json.put("id_token", idToken);
+ }
+ return json.toJSONString();
+ }
}