diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
index bf22704f47..c562607d07 100644
--- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
+++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
@@ -241,7 +241,6 @@ public class ApplicationProperties {
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpIssuer;
- private Boolean enableSingleLogout = false;
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java
index f1b398c9e6..a98d4d4f89 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java
@@ -117,21 +117,25 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
if (reconstructedAuth.isPresent()) {
Saml2Authentication samlAuth = reconstructedAuth.get();
- if (samlLogoutHandler != null) {
- try {
- samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
- } catch (Exception e) {
- log.error("SP-initiated SLO failed, falling back to local logout", e);
- getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
- }
- } else {
- log.warn(
- "SAML SLO enabled but handler not configured, performing local logout only");
- getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
+ if (samlLogoutHandler != null) {
+ try {
+ samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
+ return;
+ } catch (Exception e) {
+ log.error("SP-initiated SLO failed, falling back to local logout", e);
}
+ } else {
+ log.warn(
+ "SAML SLO enabled but handler not configured, performing local logout only");
}
}
}
+ // Fall through to local logout for all cases:
+ // - SLO disabled
+ // - SLO enabled but reconstruction failed
+ // - SLO enabled but handler not configured
+ // - SLO failed with exception
+ getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
/**
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java
index d7163f3ea8..2edbe93c34 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java
@@ -1,21 +1,30 @@
package stirling.software.proprietary.security;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
+import java.net.URL;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
+import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClient.RequestHeadersUriSpec;
+import org.springframework.web.client.RestClient.ResponseSpec;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -23,6 +32,13 @@ import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.service.JwtServiceInterface;
+/**
+ * Tests for CustomLogoutSuccessHandler.
+ *
+ *
The handler uses JWT-based authentication to determine logout strategy: - OAUTH2 authType:
+ * Redirects to OIDC provider's end_session_endpoint - SAML2 authType: Delegates to SAML logout
+ * handler - Other/null: Local logout redirect
+ */
@ExtendWith(MockitoExtension.class)
class CustomLogoutSuccessHandlerTest {
@@ -40,14 +56,12 @@ class CustomLogoutSuccessHandlerTest {
}
@Test
- void testSuccessfulLogout() throws IOException {
+ void testSuccessfulLogout_NullAuthentication() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String logoutPath = "/login?logout=true";
when(response.isCommitted()).thenReturn(false);
- when(securityProperties.getSaml2()).thenReturn(saml2);
- when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
@@ -57,1055 +71,36 @@ class CustomLogoutSuccessHandlerTest {
}
@Test
- void testSuccessfulLogoutViaJWT() throws IOException {
+ void testJwtLogout_WebAuthType_RedirectsToLocalLogout() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
+ JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
+ Jwt jwt = mock(Jwt.class);
String logoutPath = "/login?logout=true";
when(response.isCommitted()).thenReturn(false);
- when(securityProperties.getSaml2()).thenReturn(saml2);
- when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
- customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
+ when(jwtAuth.getToken()).thenReturn(jwt);
+ when(jwt.getClaims()).thenReturn(Map.of("authType", "WEB"));
+
+ customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
verify(response).sendRedirect(logoutPath);
}
@Test
- void testSuccessfulLogoutViaOAuth2() throws IOException {
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken oAuth2AuthenticationToken = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("SAMLResponse")).thenReturn(null);
- when(securityProperties.getSaml2()).thenReturn(saml2);
- when(saml2.getEnableSingleLogout()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
- when(oAuth2AuthenticationToken.getAuthorizedClientRegistrationId()).thenReturn("test");
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, oAuth2AuthenticationToken);
-
- verify(response).sendRedirect("http://localhost:8080/login?logout=true");
- }
-
- @Test
- void testUserIsDisabledRedirect() throws IOException {
- String error = "userIsDisabled";
- String url = "http://localhost:8080";
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("SAMLResponse")).thenReturn(null);
- when(securityProperties.getSaml2()).thenReturn(saml2);
- when(saml2.getEnableSingleLogout()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
- when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
- when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
- when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
- when(request.getParameter("maxUsersReached")).thenReturn(null);
- when(request.getParameter(error)).thenReturn("true");
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
- }
-
- @Test
- void testUserAlreadyExistsWebRedirect() throws IOException {
- String error = "oAuth2AuthenticationErrorWeb";
- String errorPath = "userAlreadyExistsWeb";
- String url = "http://localhost:8080";
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("SAMLResponse")).thenReturn(null);
- when(securityProperties.getSaml2()).thenReturn(saml2);
- when(saml2.getEnableSingleLogout()).thenReturn(false);
- when(request.getParameter(error)).thenReturn("true");
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- verify(response).sendRedirect(url + "/login?errorOAuth=" + errorPath);
- }
-
- @Test
- void testErrorOAuthRedirect() throws IOException {
- String error = "testError";
- String url = "http://localhost:8080";
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("SAMLResponse")).thenReturn(null);
- when(securityProperties.getSaml2()).thenReturn(saml2);
- when(saml2.getEnableSingleLogout()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn("!!!" + error + "!!!");
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
- }
-
- @Test
- void testOAuth2AutoCreateDisabled() throws IOException {
- String error = "oAuth2AutoCreateDisabled";
- String url = "http://localhost:8080";
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("SAMLResponse")).thenReturn(null);
- when(securityProperties.getSaml2()).thenReturn(saml2);
- when(saml2.getEnableSingleLogout()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getParameter(error)).thenReturn("true");
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
- }
-
- @Test
- void testOAuth2Error() throws IOException {
- String error = "test";
- String url = "http://localhost:8080";
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("SAMLResponse")).thenReturn(null);
- when(securityProperties.getSaml2()).thenReturn(saml2);
- when(saml2.getEnableSingleLogout()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
- when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
- when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
- when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
- when(request.getParameter("maxUsersReached")).thenReturn(null);
- when(request.getParameter("userIsDisabled")).thenReturn(null);
- when(request.getParameter("error")).thenReturn("!@$!@£" + error + "£$%^*$");
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
- }
-
- @Test
- void testOAuth2BadCredentialsError() throws IOException {
- String error = "badCredentials";
- String url = "http://localhost:8080";
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("SAMLResponse")).thenReturn(null);
- when(securityProperties.getSaml2()).thenReturn(saml2);
- when(saml2.getEnableSingleLogout()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
- when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
- when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
- when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
- when(request.getParameter("maxUsersReached")).thenReturn(null);
- when(request.getParameter("userIsDisabled")).thenReturn(null);
- when(request.getParameter("error")).thenReturn(null);
- when(request.getParameter(error)).thenReturn("true");
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
- }
-
- @Test
- void testOAuth2AdminBlockedUser() throws IOException {
- String error = "oAuth2AdminBlockedUser";
- String url = "http://localhost:8080";
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("SAMLResponse")).thenReturn(null);
- when(securityProperties.getSaml2()).thenReturn(saml2);
- when(saml2.getEnableSingleLogout()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
- when(request.getParameter(error)).thenReturn("true");
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
- }
-
- @Test
- void testKeycloakLogoutWithOidcUser_IncludesIdTokenHint() throws IOException {
- // Test that Keycloak logout with OidcUser includes id_token_hint parameter
- String idTokenValue = "test.id.token";
- String issuerUrl = "https://keycloak.example.com/realms/test";
- String clientId = "stirling-pdf";
- String redirectUrl = "http://localhost:8080/login?logout=true";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
- ApplicationProperties.Security.OAUTH2.Client client =
- mock(ApplicationProperties.Security.OAUTH2.Client.class);
- KeycloakProvider keycloakProvider = mock(KeycloakProvider.class);
-
- // Create OidcUser with id token
- OidcIdToken idToken =
- new OidcIdToken(
- idTokenValue,
- Instant.now(),
- Instant.now().plusSeconds(3600),
- java.util.Map.of("sub", "user123"));
- OidcUser oidcUser = mock(OidcUser.class);
- when(oidcUser.getIdToken()).thenReturn(idToken);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getClient()).thenReturn(client);
- when(client.getEndSessionEndpoint()).thenReturn(null); // Not configured
- when(client.getKeycloak()).thenReturn(keycloakProvider);
- when(keycloakProvider.getIssuer()).thenReturn(issuerUrl);
- when(keycloakProvider.getClientId()).thenReturn(clientId);
-
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("keycloak");
- when(authentication.getPrincipal()).thenReturn(oidcUser);
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify the logout URL contains id_token_hint
- // Note: With new logic, uses Keycloak fallback path
- verify(response).sendRedirect(contains(issuerUrl + "/protocol/openid-connect/logout"));
- verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
- verify(response).sendRedirect(contains("post_logout_redirect_uri="));
- verify(response).sendRedirect(contains("client_id=" + clientId));
- }
-
- @Test
- void testKeycloakLogoutWithoutOidcUser_FallsBackToClientId() throws IOException {
- // Test that Keycloak logout without OidcUser falls back to client_id only
- String issuerUrl = "https://keycloak.example.com/realms/test";
- String clientId = "stirling-pdf";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
- ApplicationProperties.Security.OAUTH2.Client client =
- mock(ApplicationProperties.Security.OAUTH2.Client.class);
- KeycloakProvider keycloakProvider = mock(KeycloakProvider.class);
-
- // Create non-OIDC OAuth2User (no id token available)
- OAuth2User oauth2User = mock(OAuth2User.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getClient()).thenReturn(client);
- when(client.getEndSessionEndpoint()).thenReturn(null); // Not configured
- when(client.getKeycloak()).thenReturn(keycloakProvider);
- when(keycloakProvider.getIssuer()).thenReturn(issuerUrl);
- when(keycloakProvider.getClientId()).thenReturn(clientId);
-
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("keycloak");
- when(authentication.getPrincipal()).thenReturn(oauth2User);
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify the logout URL uses client_id without id_token_hint
- verify(response).sendRedirect(contains("/protocol/openid-connect/logout"));
- verify(response).sendRedirect(contains("client_id=" + clientId));
- verify(response).sendRedirect(contains("post_logout_redirect_uri="));
- }
-
- @Test
- void testKeycloakLogoutWithCustomOAuth_UsesCustomIssuer() throws IOException {
- // Test that custom OAuth provider uses custom issuer URL
- String customIssuerUrl = "https://custom-oauth.example.com";
- String clientId = "stirling-pdf";
- String idTokenValue = "custom.id.token";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
- ApplicationProperties.Security.OAUTH2.Client client =
- mock(ApplicationProperties.Security.OAUTH2.Client.class);
- KeycloakProvider keycloakProvider = mock(KeycloakProvider.class);
-
- // Create OidcUser with id token
- OidcIdToken idToken =
- new OidcIdToken(
- idTokenValue,
- Instant.now(),
- Instant.now().plusSeconds(3600),
- java.util.Map.of("sub", "user123"));
- OidcUser oidcUser = mock(OidcUser.class);
- when(oidcUser.getIdToken()).thenReturn(idToken);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getClient()).thenReturn(client);
- when(client.getEndSessionEndpoint()).thenReturn(null); // Not configured
- when(client.getKeycloak()).thenReturn(keycloakProvider);
- when(keycloakProvider.getIssuer()).thenReturn(""); // Empty keycloak issuer
- when(oauth.getIssuer()).thenReturn(customIssuerUrl); // Use custom issuer
- when(oauth.getClientId()).thenReturn(clientId);
-
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("keycloak");
- when(authentication.getPrincipal()).thenReturn(oidcUser);
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify custom issuer is used with Keycloak fallback path
- verify(response)
- .sendRedirect(contains(customIssuerUrl + "/protocol/openid-connect/logout"));
- verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
- }
-
- @Test
- void testGitHubLogout_RedirectsToLocalLogout() throws IOException {
- // Test that GitHub logout redirects to local logout page (no provider logout)
- String redirectUrl = "http://localhost:8080/login?logout=true";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("github");
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify redirect to local logout page
- verify(response).sendRedirect(redirectUrl);
- }
-
- @Test
- void testGoogleLogout_RedirectsToLocalLogout() throws IOException {
- // Test that Google logout redirects to local logout page (no provider logout)
- String redirectUrl = "http://localhost:8080/login?logout=true";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("google");
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify redirect to local logout page
- verify(response).sendRedirect(redirectUrl);
- }
-
- @Test
- void testSaml2LogoutSuccess_RedirectsToIdentityProvider() throws Exception {
- // Test successful SAML2 logout with redirect to IdP
- // Note: This test verifies the handler processes SAML2 logout without exceptions
- // In a real scenario, SamlClient would redirect to the IdP
- String registrationId = "test-saml";
- String providerName = "TestIdP";
- String nameIdValue = "user@example.com";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- Saml2Authentication authentication = mock(Saml2Authentication.class);
- CustomSaml2AuthenticatedPrincipal principal = mock(CustomSaml2AuthenticatedPrincipal.class);
- ApplicationProperties.Security.SAML2 saml2Config =
- mock(ApplicationProperties.Security.SAML2.class);
-
- Resource certResource = mock(Resource.class);
- Resource keyResource = mock(Resource.class);
- X509Certificate certificate = mock(X509Certificate.class);
- RSAPrivateKey privateKey = mock(RSAPrivateKey.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getSaml2()).thenReturn(saml2Config);
- when(saml2Config.getRegistrationId()).thenReturn(registrationId);
- when(saml2Config.getProvider()).thenReturn(providerName);
- when(saml2Config.getSpCert()).thenReturn(certResource);
- when(saml2Config.getPrivateKey()).thenReturn(keyResource);
- when(saml2Config.getIdpSingleLogoutUrl()).thenReturn("https://idp.example.com/logout");
- when(saml2Config.getIdpIssuer()).thenReturn("https://idp.example.com");
-
- when(authentication.getPrincipal()).thenReturn(principal);
- when(principal.name()).thenReturn(nameIdValue);
-
- when(appConfig.getBaseUrl()).thenReturn("http://localhost");
- when(appConfig.getServerPort()).thenReturn("8080");
-
- // Use static mocking for CertificateUtils
- try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) {
- certUtils
- .when(() -> CertificateUtils.readCertificate(certResource))
- .thenReturn(certificate);
- certUtils
- .when(() -> CertificateUtils.readPrivateKey(keyResource))
- .thenReturn(privateKey);
-
- // This should complete without throwing an exception
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
- // Success is verified by no exception being thrown
- }
- }
-
- @Test
- void testSaml2LogoutFailure_FallsBackToLocalLogout() throws Exception {
- // Test SAML2 logout with exception falls back to local logout
- String registrationId = "test-saml";
- String providerName = "TestIdP";
- String nameIdValue = "user@example.com";
- String localLogoutPath = "/login?logout=true";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- Saml2Authentication authentication = mock(Saml2Authentication.class);
- CustomSaml2AuthenticatedPrincipal principal = mock(CustomSaml2AuthenticatedPrincipal.class);
- ApplicationProperties.Security.SAML2 saml2Config =
- mock(ApplicationProperties.Security.SAML2.class);
-
- Resource certResource = mock(Resource.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(response.encodeRedirectURL(anyString())).thenAnswer(i -> i.getArguments()[0]);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getSaml2()).thenReturn(saml2Config);
- when(saml2Config.getRegistrationId()).thenReturn(registrationId);
- when(saml2Config.getProvider()).thenReturn(providerName);
- when(saml2Config.getSpCert()).thenReturn(certResource);
-
- when(authentication.getPrincipal()).thenReturn(principal);
- when(principal.name()).thenReturn(nameIdValue);
-
- // Simulate exception when reading certificate
- try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) {
- certUtils
- .when(() -> CertificateUtils.readCertificate(certResource))
- .thenThrow(new RuntimeException("Failed to read certificate"));
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify fallback to local logout via redirect strategy
- verify(response).sendRedirect(localLogoutPath);
- }
- }
-
- @Test
- void testSaml2LogoutWithCertificateError_RedirectsToLocalLogout() throws Exception {
- // Test SAML2 logout with certificate reading error
- String registrationId = "test-saml";
- String providerName = "TestIdP";
- String nameIdValue = "user@example.com";
- String localLogoutPath = "/login?logout=true";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- Saml2Authentication authentication = mock(Saml2Authentication.class);
- CustomSaml2AuthenticatedPrincipal principal = mock(CustomSaml2AuthenticatedPrincipal.class);
- ApplicationProperties.Security.SAML2 saml2Config =
- mock(ApplicationProperties.Security.SAML2.class);
-
- Resource certResource = mock(Resource.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(response.encodeRedirectURL(anyString())).thenAnswer(i -> i.getArguments()[0]);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getSaml2()).thenReturn(saml2Config);
- when(saml2Config.getRegistrationId()).thenReturn(registrationId);
- when(saml2Config.getProvider()).thenReturn(providerName);
- when(saml2Config.getSpCert()).thenReturn(certResource);
-
- when(authentication.getPrincipal()).thenReturn(principal);
- when(principal.name()).thenReturn(nameIdValue);
-
- // Simulate certificate error
- try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) {
- certUtils
- .when(() -> CertificateUtils.readCertificate(certResource))
- .thenThrow(
- new java.security.cert.CertificateException(
- "Invalid certificate format"));
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify fallback to local logout via redirect strategy
- verify(response).sendRedirect(localLogoutPath);
- }
- }
-
- @Test
- void testSaml2LogoutWithPrivateKeyError_RedirectsToLocalLogout() throws Exception {
- // Test SAML2 logout with private key reading error
- String registrationId = "test-saml";
- String providerName = "TestIdP";
- String nameIdValue = "user@example.com";
- String localLogoutPath = "/login?logout=true";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- Saml2Authentication authentication = mock(Saml2Authentication.class);
- CustomSaml2AuthenticatedPrincipal principal = mock(CustomSaml2AuthenticatedPrincipal.class);
- ApplicationProperties.Security.SAML2 saml2Config =
- mock(ApplicationProperties.Security.SAML2.class);
-
- Resource certResource = mock(Resource.class);
- Resource keyResource = mock(Resource.class);
- X509Certificate certificate = mock(X509Certificate.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(response.encodeRedirectURL(anyString())).thenAnswer(i -> i.getArguments()[0]);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getSaml2()).thenReturn(saml2Config);
- when(saml2Config.getRegistrationId()).thenReturn(registrationId);
- when(saml2Config.getProvider()).thenReturn(providerName);
- when(saml2Config.getSpCert()).thenReturn(certResource);
- when(saml2Config.getPrivateKey()).thenReturn(keyResource);
- when(saml2Config.getIdpSingleLogoutUrl()).thenReturn("https://idp.example.com/logout");
- when(saml2Config.getIdpIssuer()).thenReturn("https://idp.example.com");
-
- when(authentication.getPrincipal()).thenReturn(principal);
- when(principal.name()).thenReturn(nameIdValue);
-
- when(appConfig.getBaseUrl()).thenReturn("http://localhost");
- when(appConfig.getServerPort()).thenReturn("8080");
-
- // Certificate reads successfully but private key fails
- try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) {
- certUtils
- .when(() -> CertificateUtils.readCertificate(certResource))
- .thenReturn(certificate);
- certUtils
- .when(() -> CertificateUtils.readPrivateKey(keyResource))
- .thenThrow(new RuntimeException("Failed to read private key"));
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify fallback to local logout via redirect strategy
- verify(response).sendRedirect(localLogoutPath);
- }
- }
-
- @Test
- void testGenericOidcProvider_WithConfiguredEndpoint_SkipsDiscovery() throws IOException {
- // Test that configured endSessionEndpoint takes priority over discovery
- String configuredEndpoint = "https://authentik.example.com/application/o/end-session/";
- String issuerUrl = "https://authentik.example.com/application/o/stirling-pdf/";
- String clientId = "stirling-pdf";
- String idTokenValue = "test.id.token";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
- ApplicationProperties.Security.OAUTH2.Client client =
- mock(ApplicationProperties.Security.OAUTH2.Client.class);
-
- // Create OidcUser with id token
- OidcIdToken idToken =
- new OidcIdToken(
- idTokenValue,
- Instant.now(),
- Instant.now().plusSeconds(3600),
- java.util.Map.of("sub", "user123"));
- OidcUser oidcUser = mock(OidcUser.class);
- when(oidcUser.getIdToken()).thenReturn(idToken);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getClient()).thenReturn(client);
- when(client.getEndSessionEndpoint())
- .thenReturn(configuredEndpoint); // Configured endpoint provided
- when(oauth.getIssuer()).thenReturn(issuerUrl);
- when(oauth.getClientId()).thenReturn(clientId);
-
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("authentik");
- when(authentication.getPrincipal()).thenReturn(oidcUser);
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify configured endpoint is used (no discovery should happen)
- verify(response).sendRedirect(contains(configuredEndpoint));
- verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
- verify(response).sendRedirect(contains("post_logout_redirect_uri="));
- verify(response).sendRedirect(contains("client_id=" + clientId));
- }
-
- @Test
- void testGenericOidcProvider_WithSuccessfulDiscovery() throws IOException {
- // Test that generic OIDC provider uses discovered endpoint
- String issuerUrl = "https://authentik.example.com/application/o/stirling-pdf";
- String discoveredEndpoint = "https://authentik.example.com/application/o/end-session/";
- String clientId = "stirling-pdf";
- String idTokenValue = "test.id.token";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
- ApplicationProperties.Security.OAUTH2.Client client =
- mock(ApplicationProperties.Security.OAUTH2.Client.class);
-
- // Create OidcUser with id token
- OidcIdToken idToken =
- new OidcIdToken(
- idTokenValue,
- Instant.now(),
- Instant.now().plusSeconds(3600),
- java.util.Map.of("sub", "user123"));
- OidcUser oidcUser = mock(OidcUser.class);
- when(oidcUser.getIdToken()).thenReturn(idToken);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getClient()).thenReturn(client);
- when(client.getEndSessionEndpoint()).thenReturn(null); // No configured endpoint
- when(oauth.getIssuer()).thenReturn(issuerUrl);
- when(oauth.getClientId()).thenReturn(clientId);
-
- when(authentication.getAuthorizedClientRegistrationId()).thenReturn("authentik");
- when(authentication.getPrincipal()).thenReturn(oidcUser);
-
- // Mock RestClient for discovery
- try (MockedStatic restClientStatic = mockStatic(RestClient.class)) {
- @SuppressWarnings({"rawtypes", "unchecked"})
- RestClient.Builder mockBuilder = mock(RestClient.Builder.class);
- @SuppressWarnings({"rawtypes", "unchecked"})
- RestClient mockRestClient = mock(RestClient.class);
- @SuppressWarnings({"rawtypes", "unchecked"})
- RequestHeadersUriSpec mockRequestSpec = mock(RequestHeadersUriSpec.class);
- @SuppressWarnings({"rawtypes", "unchecked"})
- ResponseSpec mockResponseSpec = mock(ResponseSpec.class);
-
- restClientStatic.when(RestClient::builder).thenReturn(mockBuilder);
- when(mockBuilder.baseUrl(anyString())).thenReturn(mockBuilder);
- when(mockBuilder.defaultHeaders(any())).thenReturn(mockBuilder);
- when(mockBuilder.build()).thenReturn(mockRestClient);
- when(mockRestClient.get()).thenReturn(mockRequestSpec);
- when(mockRequestSpec.retrieve()).thenReturn(mockResponseSpec);
- when(mockResponseSpec.onStatus(any(), any())).thenReturn(mockResponseSpec);
-
- // Mock discovery document response
- Map discoveryDoc = Map.of("end_session_endpoint", discoveredEndpoint);
- when(mockResponseSpec.body(Map.class)).thenReturn(discoveryDoc);
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify discovered endpoint is used
- verify(response).sendRedirect(contains(discoveredEndpoint));
- verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
- verify(response).sendRedirect(contains("post_logout_redirect_uri="));
- verify(response).sendRedirect(contains("client_id=" + clientId));
- }
- }
-
- @Test
- void testGenericOidcProvider_WithFailedDiscovery_FallsBackToLocalLogout() throws IOException {
- // Test that failed discovery falls back to local logout
- String issuerUrl = "https://cloudron.example.com";
- String clientId = "stirling-pdf";
- String redirectUrl = "http://localhost:8080/login?logout=true";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
- ApplicationProperties.Security.OAUTH2.Client client =
- mock(ApplicationProperties.Security.OAUTH2.Client.class);
-
- OAuth2User oauth2User = mock(OAuth2User.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getOauth2()).thenReturn(oauth);
- lenient().when(oauth.getClient()).thenReturn(client);
- lenient().when(client.getEndSessionEndpoint()).thenReturn(null); // No configured endpoint
- lenient().when(oauth.getIssuer()).thenReturn(issuerUrl);
- lenient().when(oauth.getClientId()).thenReturn(clientId);
-
- lenient().when(authentication.getAuthorizedClientRegistrationId()).thenReturn("cloudron");
- lenient().when(authentication.getPrincipal()).thenReturn(oauth2User);
-
- // Mock RestClient for failed discovery
- try (MockedStatic restClientStatic = mockStatic(RestClient.class)) {
- @SuppressWarnings({"rawtypes", "unchecked"})
- RestClient.Builder mockBuilder = mock(RestClient.Builder.class);
- @SuppressWarnings({"rawtypes", "unchecked"})
- RestClient mockRestClient = mock(RestClient.class);
- @SuppressWarnings({"rawtypes", "unchecked"})
- RequestHeadersUriSpec mockRequestSpec = mock(RequestHeadersUriSpec.class);
- @SuppressWarnings({"rawtypes", "unchecked"})
- ResponseSpec mockResponseSpec = mock(ResponseSpec.class);
-
- restClientStatic.when(RestClient::builder).thenReturn(mockBuilder);
- when(mockBuilder.baseUrl(anyString())).thenReturn(mockBuilder);
- when(mockBuilder.defaultHeaders(any())).thenReturn(mockBuilder);
- when(mockBuilder.build()).thenReturn(mockRestClient);
- when(mockRestClient.get()).thenReturn(mockRequestSpec);
- when(mockRequestSpec.retrieve()).thenReturn(mockResponseSpec);
- when(mockResponseSpec.onStatus(any(), any())).thenReturn(mockResponseSpec);
-
- // Mock discovery document without end_session_endpoint
- Map discoveryDoc =
- Map.of("issuer", issuerUrl, "authorization_endpoint", issuerUrl + "/oauth");
- when(mockResponseSpec.body(Map.class)).thenReturn(discoveryDoc);
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify fallback to local logout
- verify(response).sendRedirect(redirectUrl);
- }
- }
-
- @Test
- void testGenericOidcProvider_WithDiscoveryException_FallsBackToLocalLogout()
+ void testJwtLogout_OAuth2AuthType_WithConfiguredEndpoint_RedirectsToProvider()
throws IOException {
- // Test that discovery exceptions fall back to local logout
- String issuerUrl = "https://broken.example.com";
- String redirectUrl = "http://localhost:8080/login?logout=true";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
- ApplicationProperties.Security.OAUTH2.Client client =
- mock(ApplicationProperties.Security.OAUTH2.Client.class);
-
- OAuth2User oauth2User = mock(OAuth2User.class);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
-
- when(securityProperties.getOauth2()).thenReturn(oauth);
- lenient().when(oauth.getClient()).thenReturn(client);
- lenient().when(client.getEndSessionEndpoint()).thenReturn(null);
- lenient().when(oauth.getIssuer()).thenReturn(issuerUrl);
-
- lenient().when(authentication.getAuthorizedClientRegistrationId()).thenReturn("broken");
- lenient().when(authentication.getPrincipal()).thenReturn(oauth2User);
-
- // Mock RestClient to throw exception
- try (MockedStatic restClientStatic = mockStatic(RestClient.class)) {
- @SuppressWarnings({"rawtypes", "unchecked"})
- RestClient.Builder mockBuilder = mock(RestClient.Builder.class);
- @SuppressWarnings({"rawtypes", "unchecked"})
- RestClient mockRestClient = mock(RestClient.class);
- @SuppressWarnings({"rawtypes", "unchecked"})
- RequestHeadersUriSpec mockRequestSpec = mock(RequestHeadersUriSpec.class);
- @SuppressWarnings({"rawtypes", "unchecked"})
- ResponseSpec mockResponseSpec = mock(ResponseSpec.class);
-
- restClientStatic.when(RestClient::builder).thenReturn(mockBuilder);
- when(mockBuilder.baseUrl(anyString())).thenReturn(mockBuilder);
- when(mockBuilder.defaultHeaders(any())).thenReturn(mockBuilder);
- when(mockBuilder.build()).thenReturn(mockRestClient);
- when(mockRestClient.get()).thenReturn(mockRequestSpec);
- when(mockRequestSpec.retrieve()).thenReturn(mockResponseSpec);
- when(mockResponseSpec.onStatus(any(), any())).thenReturn(mockResponseSpec);
- when(mockResponseSpec.body(Map.class)).thenThrow(new RuntimeException("Network error"));
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
-
- // Verify fallback to local logout
- verify(response).sendRedirect(redirectUrl);
- }
- }
-
- @Test
- void testJwtLogout_ApiRequest_ReturnsJsonWithLogoutUrl() throws IOException {
- // Test that API requests (Accept: application/json) get JSON response with logout URL
String issuerUrl = "https://keycloak.example.com/realms/test";
String clientId = "stirling-pdf";
String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
- org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
- jwtAuth =
- mock(
- org.springframework.security.oauth2.server.resource.authentication
- .JwtAuthenticationToken.class);
- org.springframework.security.oauth2.jwt.Jwt jwt =
- mock(org.springframework.security.oauth2.jwt.Jwt.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
- ApplicationProperties.Security.OAUTH2.Client client =
- mock(ApplicationProperties.Security.OAUTH2.Client.class);
-
- StringWriter stringWriter = new StringWriter();
- PrintWriter printWriter = new PrintWriter(stringWriter);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(request.getHeader("Accept")).thenReturn("application/json"); // API request
- when(request.getHeader("X-Requested-With")).thenReturn(null);
- when(response.getWriter()).thenReturn(printWriter);
-
- when(jwtAuth.getToken()).thenReturn(jwt);
- when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
- when(jwt.getIssuer()).thenReturn(new java.net.URL(issuerUrl));
-
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getClient()).thenReturn(client);
- when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint); // Configured endpoint
- when(oauth.getClientId()).thenReturn(clientId);
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
-
- // Verify JSON response
- verify(response).setStatus(HttpServletResponse.SC_OK);
- verify(response).setContentType("application/json");
- verify(response).setCharacterEncoding("UTF-8");
- verify(response).getWriter();
-
- String jsonResponse = stringWriter.toString();
- assert jsonResponse.contains("\"logoutUrl\":");
- assert jsonResponse.contains(issuerUrl);
- }
-
- @Test
- void testJwtLogout_XhrRequest_ReturnsJsonWithLogoutUrl() throws IOException {
- // Test that XHR requests (X-Requested-With: XMLHttpRequest) get JSON response
- String issuerUrl = "https://keycloak.example.com/realms/test";
- String clientId = "stirling-pdf";
- String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
- jwtAuth =
- mock(
- org.springframework.security.oauth2.server.resource.authentication
- .JwtAuthenticationToken.class);
- org.springframework.security.oauth2.jwt.Jwt jwt =
- mock(org.springframework.security.oauth2.jwt.Jwt.class);
- ApplicationProperties.Security.OAUTH2 oauth =
- mock(ApplicationProperties.Security.OAUTH2.class);
- ApplicationProperties.Security.OAUTH2.Client client =
- mock(ApplicationProperties.Security.OAUTH2.Client.class);
-
- StringWriter stringWriter = new StringWriter();
- PrintWriter printWriter = new PrintWriter(stringWriter);
-
- when(response.isCommitted()).thenReturn(false);
- when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
- when(request.getParameter("errorOAuth")).thenReturn(null);
- when(request.getScheme()).thenReturn("http");
- when(request.getServerName()).thenReturn("localhost");
- when(request.getServerPort()).thenReturn(8080);
- when(request.getContextPath()).thenReturn("");
- when(request.getHeader("Accept")).thenReturn("text/html"); // Not JSON Accept header
- when(request.getHeader("X-Requested-With")).thenReturn("XMLHttpRequest"); // XHR request
- when(response.getWriter()).thenReturn(printWriter);
-
- when(jwtAuth.getToken()).thenReturn(jwt);
- when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
- when(jwt.getIssuer()).thenReturn(new java.net.URL(issuerUrl));
-
- when(securityProperties.getOauth2()).thenReturn(oauth);
- when(oauth.getClient()).thenReturn(client);
- when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint); // Configured endpoint
- when(oauth.getClientId()).thenReturn(clientId);
-
- customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
-
- // Verify JSON response
- verify(response).setStatus(HttpServletResponse.SC_OK);
- verify(response).setContentType("application/json");
- }
-
- @Test
- void testJwtLogout_BrowserRequest_RedirectsToLogoutUrl() throws IOException {
- // Test that browser requests (no Accept: application/json) get redirected
- String issuerUrl = "https://keycloak.example.com/realms/test";
- String clientId = "stirling-pdf";
- String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
-
- HttpServletRequest request = mock(HttpServletRequest.class);
- HttpServletResponse response = mock(HttpServletResponse.class);
- org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
- jwtAuth =
- mock(
- org.springframework.security.oauth2.server.resource.authentication
- .JwtAuthenticationToken.class);
- org.springframework.security.oauth2.jwt.Jwt jwt =
- mock(org.springframework.security.oauth2.jwt.Jwt.class);
+ JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
+ Jwt jwt = mock(Jwt.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
ApplicationProperties.Security.OAUTH2.Client client =
@@ -1118,38 +113,111 @@ class CustomLogoutSuccessHandlerTest {
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
- when(request.getHeader("Accept")).thenReturn("text/html"); // Browser request
+ when(request.getHeader("Accept")).thenReturn("text/html");
when(request.getHeader("X-Requested-With")).thenReturn(null);
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
- when(jwt.getIssuer()).thenReturn(new java.net.URL(issuerUrl));
+ when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
when(securityProperties.getOauth2()).thenReturn(oauth);
when(oauth.getClient()).thenReturn(client);
- when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint); // Configured endpoint
+ when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
when(oauth.getClientId()).thenReturn(clientId);
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
- // Verify redirect (not JSON)
verify(response).sendRedirect(contains(endSessionEndpoint));
verify(response).sendRedirect(contains("client_id=" + clientId));
verify(response).sendRedirect(contains("post_logout_redirect_uri="));
}
@Test
- void testJwtLogout_ApiRequest_NoOidcEndpoint_ReturnsLocalLogoutUrl() throws IOException {
- // Test that API requests with no OIDC endpoint return local logout URL as JSON
+ void testJwtLogout_OAuth2AuthType_WithIdToken_IncludesIdTokenHint() throws IOException {
+ String issuerUrl = "https://keycloak.example.com/realms/test";
+ String clientId = "stirling-pdf";
+ String idTokenValue = "test.id.token";
+ String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
+
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
- org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
- jwtAuth =
- mock(
- org.springframework.security.oauth2.server.resource.authentication
- .JwtAuthenticationToken.class);
- org.springframework.security.oauth2.jwt.Jwt jwt =
- mock(org.springframework.security.oauth2.jwt.Jwt.class);
+ JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
+ Jwt jwt = mock(Jwt.class);
+ ApplicationProperties.Security.OAUTH2 oauth =
+ mock(ApplicationProperties.Security.OAUTH2.class);
+ ApplicationProperties.Security.OAUTH2.Client client =
+ mock(ApplicationProperties.Security.OAUTH2.Client.class);
+
+ when(response.isCommitted()).thenReturn(false);
+ when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
+ when(request.getParameter("errorOAuth")).thenReturn(null);
+ when(request.getScheme()).thenReturn("http");
+ when(request.getServerName()).thenReturn("localhost");
+ when(request.getServerPort()).thenReturn(8080);
+ when(request.getContextPath()).thenReturn("");
+ when(request.getHeader("Accept")).thenReturn("text/html");
+ when(request.getHeader("X-Requested-With")).thenReturn(null);
+
+ when(jwtAuth.getToken()).thenReturn(jwt);
+ when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2", "id_token", idTokenValue));
+ when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
+
+ when(securityProperties.getOauth2()).thenReturn(oauth);
+ when(oauth.getClient()).thenReturn(client);
+ when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
+ when(oauth.getClientId()).thenReturn(clientId);
+
+ customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
+
+ verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
+ }
+
+ @Test
+ void testJwtLogout_OAuth2AuthType_NoEndpoint_FallsBackToLocalLogout() throws IOException {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
+ Jwt jwt = mock(Jwt.class);
+ ApplicationProperties.Security.OAUTH2 oauth =
+ mock(ApplicationProperties.Security.OAUTH2.class);
+ ApplicationProperties.Security.OAUTH2.Client client =
+ mock(ApplicationProperties.Security.OAUTH2.Client.class);
+
+ when(response.isCommitted()).thenReturn(false);
+ when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
+ when(request.getParameter("errorOAuth")).thenReturn(null);
+ when(request.getScheme()).thenReturn("http");
+ when(request.getServerName()).thenReturn("localhost");
+ when(request.getServerPort()).thenReturn(8080);
+ when(request.getContextPath()).thenReturn("");
+ when(request.getHeader("Accept")).thenReturn("text/html");
+ when(request.getHeader("X-Requested-With")).thenReturn(null);
+
+ when(jwtAuth.getToken()).thenReturn(jwt);
+ when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
+ when(jwt.getIssuer()).thenReturn(null);
+
+ when(securityProperties.getOauth2()).thenReturn(oauth);
+ when(oauth.getClient()).thenReturn(client);
+ when(client.getEndSessionEndpoint()).thenReturn(null);
+ when(client.getKeycloak()).thenReturn(null);
+ when(oauth.getIssuer()).thenReturn("");
+
+ customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
+
+ verify(response).sendRedirect("http://localhost:8080/login?logout=true");
+ }
+
+ @Test
+ void testJwtLogout_ApiRequest_ReturnsJsonWithLogoutUrl() throws IOException {
+ String issuerUrl = "https://keycloak.example.com/realms/test";
+ String clientId = "stirling-pdf";
+ String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
+ Jwt jwt = mock(Jwt.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
ApplicationProperties.Security.OAUTH2.Client client =
@@ -1171,21 +239,230 @@ class CustomLogoutSuccessHandlerTest {
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
+ when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
+
+ when(securityProperties.getOauth2()).thenReturn(oauth);
+ when(oauth.getClient()).thenReturn(client);
+ when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
+ when(oauth.getClientId()).thenReturn(clientId);
+
+ customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
+
+ verify(response).setStatus(HttpServletResponse.SC_OK);
+ verify(response).setContentType("application/json");
+ verify(response).setCharacterEncoding("UTF-8");
+
+ String jsonResponse = stringWriter.toString();
+ assert jsonResponse.contains("\"logoutUrl\":");
+ assert jsonResponse.contains(issuerUrl);
+ }
+
+ @Test
+ void testJwtLogout_XhrRequest_ReturnsJsonWithLogoutUrl() throws IOException {
+ String issuerUrl = "https://keycloak.example.com/realms/test";
+ String clientId = "stirling-pdf";
+ String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
+ Jwt jwt = mock(Jwt.class);
+ ApplicationProperties.Security.OAUTH2 oauth =
+ mock(ApplicationProperties.Security.OAUTH2.class);
+ ApplicationProperties.Security.OAUTH2.Client client =
+ mock(ApplicationProperties.Security.OAUTH2.Client.class);
+
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+
+ when(response.isCommitted()).thenReturn(false);
+ when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
+ when(request.getParameter("errorOAuth")).thenReturn(null);
+ when(request.getScheme()).thenReturn("http");
+ when(request.getServerName()).thenReturn("localhost");
+ when(request.getServerPort()).thenReturn(8080);
+ when(request.getContextPath()).thenReturn("");
+ when(request.getHeader("Accept")).thenReturn("text/html");
+ when(request.getHeader("X-Requested-With")).thenReturn("XMLHttpRequest");
+ when(response.getWriter()).thenReturn(printWriter);
+
+ when(jwtAuth.getToken()).thenReturn(jwt);
+ when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
+ when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
+
+ when(securityProperties.getOauth2()).thenReturn(oauth);
+ when(oauth.getClient()).thenReturn(client);
+ when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
+ when(oauth.getClientId()).thenReturn(clientId);
+
+ customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
+
+ verify(response).setStatus(HttpServletResponse.SC_OK);
+ verify(response).setContentType("application/json");
+ }
+
+ @Test
+ void testJwtLogout_Saml2AuthType_WithSloEnabled_NoHandler_LocalLogout() throws IOException {
+ // When SLO is enabled but no SAML logout handler is configured, falls back to local logout
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
+ Jwt jwt = mock(Jwt.class);
+ String logoutPath = "/login?logout=true";
+
+ when(response.isCommitted()).thenReturn(false);
+ when(request.getParameter("SAMLResponse")).thenReturn(null);
+ when(request.getContextPath()).thenReturn("");
+ when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
+
+ when(jwtAuth.getToken()).thenReturn(jwt);
+ when(jwt.getClaims())
+ .thenReturn(
+ Map.of(
+ "authType", "SAML2",
+ "sub", "testuser",
+ "samlNameId", "testuser@example.com",
+ "samlRegistrationId", "test-idp"));
+
+ when(securityProperties.getSaml2()).thenReturn(saml2);
+ when(saml2.getEnableSingleLogout()).thenReturn(true);
+
+ customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
+
+ // Falls back to local logout since no SAML handler is configured
+ verify(response).sendRedirect(logoutPath);
+ }
+
+ @Test
+ void testJwtLogout_Saml2AuthType_WithSloDisabled_LocalLogout() throws IOException {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
+ Jwt jwt = mock(Jwt.class);
+ String logoutPath = "/login?logout=true";
+
+ when(response.isCommitted()).thenReturn(false);
+ when(request.getParameter("SAMLResponse")).thenReturn(null);
+ when(request.getContextPath()).thenReturn("");
+ when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
+
+ when(jwtAuth.getToken()).thenReturn(jwt);
+ when(jwt.getClaims()).thenReturn(Map.of("authType", "SAML2"));
+
+ when(securityProperties.getSaml2()).thenReturn(saml2);
+ when(saml2.getEnableSingleLogout()).thenReturn(false);
+
+ customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
+
+ verify(response).sendRedirect(logoutPath);
+ }
+
+ @Test
+ void testErrorParameterHandling_UserIsDisabled() throws IOException {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ String logoutPath = "/login?errorOAuth=userIsDisabled";
+
+ when(response.isCommitted()).thenReturn(false);
+ when(request.getContextPath()).thenReturn("");
+ when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
+ when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
+ when(request.getParameter("errorOAuth")).thenReturn(null);
+ when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
+ when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
+ when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
+ when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
+ when(request.getParameter("maxUsersReached")).thenReturn(null);
+ when(request.getParameter("userIsDisabled")).thenReturn("true");
+
+ customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
+
+ verify(response).sendRedirect(logoutPath);
+ }
+
+ @Test
+ void testErrorParameterHandling_BadCredentials() throws IOException {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ String logoutPath = "/login?errorOAuth=badCredentials";
+
+ when(response.isCommitted()).thenReturn(false);
+ when(request.getContextPath()).thenReturn("");
+ when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
+ when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
+ when(request.getParameter("errorOAuth")).thenReturn(null);
+ when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
+ when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
+ when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
+ when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
+ when(request.getParameter("maxUsersReached")).thenReturn(null);
+ when(request.getParameter("userIsDisabled")).thenReturn(null);
+ when(request.getParameter("error")).thenReturn(null);
+ when(request.getParameter("badCredentials")).thenReturn("true");
+
+ customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
+
+ verify(response).sendRedirect(logoutPath);
+ }
+
+ @Test
+ void testOidcDiscovery_CachesEndpoint() throws IOException {
+ String issuerUrl = "https://authentik.example.com/application/o/stirling-pdf";
+ String discoveredEndpoint = "https://authentik.example.com/application/o/end-session/";
+ String clientId = "stirling-pdf";
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
+ Jwt jwt = mock(Jwt.class);
+ ApplicationProperties.Security.OAUTH2 oauth =
+ mock(ApplicationProperties.Security.OAUTH2.class);
+ ApplicationProperties.Security.OAUTH2.Client client =
+ mock(ApplicationProperties.Security.OAUTH2.Client.class);
+
+ when(response.isCommitted()).thenReturn(false);
+ when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
+ when(request.getParameter("errorOAuth")).thenReturn(null);
+ when(request.getScheme()).thenReturn("http");
+ when(request.getServerName()).thenReturn("localhost");
+ when(request.getServerPort()).thenReturn(8080);
+ when(request.getContextPath()).thenReturn("");
+ when(request.getHeader("Accept")).thenReturn("text/html");
+ when(request.getHeader("X-Requested-With")).thenReturn(null);
+
+ when(jwtAuth.getToken()).thenReturn(jwt);
+ when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
+ when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
when(securityProperties.getOauth2()).thenReturn(oauth);
when(oauth.getClient()).thenReturn(client);
when(client.getEndSessionEndpoint()).thenReturn(null);
- when(client.getKeycloak()).thenReturn(null); // No Keycloak configured
- when(oauth.getIssuer()).thenReturn(""); // No issuer
+ when(oauth.getClientId()).thenReturn(clientId);
- customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
+ try (MockedStatic restClientStatic = mockStatic(RestClient.class)) {
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ RestClient.Builder mockBuilder = mock(RestClient.Builder.class);
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ RestClient mockRestClient = mock(RestClient.class);
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ RequestHeadersUriSpec mockRequestSpec = mock(RequestHeadersUriSpec.class);
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ ResponseSpec mockResponseSpec = mock(ResponseSpec.class);
- // Verify JSON response with local logout URL
- verify(response).setStatus(HttpServletResponse.SC_OK);
- verify(response).setContentType("application/json");
+ restClientStatic.when(RestClient::builder).thenReturn(mockBuilder);
+ when(mockBuilder.baseUrl(anyString())).thenReturn(mockBuilder);
+ when(mockBuilder.defaultHeaders(any())).thenReturn(mockBuilder);
+ when(mockBuilder.build()).thenReturn(mockRestClient);
+ when(mockRestClient.get()).thenReturn(mockRequestSpec);
+ when(mockRequestSpec.retrieve()).thenReturn(mockResponseSpec);
+ when(mockResponseSpec.onStatus(any(), any())).thenReturn(mockResponseSpec);
- String jsonResponse = stringWriter.toString();
- assert jsonResponse.contains("\"logoutUrl\":");
- assert jsonResponse.contains("/login?logout=true");
+ Map discoveryDoc = Map.of("end_session_endpoint", discoveredEndpoint);
+ when(mockResponseSpec.body(Map.class)).thenReturn(discoveryDoc);
+
+ customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
+
+ verify(response).sendRedirect(contains(discoveredEndpoint));
+ }
}
}