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