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 ba0f8c9334..8cf32053cf 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 @@ -266,6 +266,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { OAUTH2 oauth = securityProperties.getOauth2(); String path = checkForErrors(request); String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path; + boolean isApi = isApiRequest(request); // For JWT-based auth, we don't have OAuth2AuthenticationToken // Attempt generic OIDC logout @@ -294,31 +295,37 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { log.debug("Using Keycloak fallback logout path: {}", endSessionEndpoint); } - // If we have an endpoint, construct the logout URL if (endSessionEndpoint != null) { StringBuilder logoutUrlBuilder = new StringBuilder(endSessionEndpoint); logoutUrlBuilder.append(endSessionEndpoint.contains("?") ? "&" : "?"); - // Without OAuth2AuthenticationToken, we don't have id_token_hint - // Just use client_id and post_logout_redirect_uri + // Use client_id and post_logout_redirect_uri if (clientId != null && !clientId.isBlank()) { - logoutUrlBuilder.append("client_id=").append(clientId); - logoutUrlBuilder.append("&"); + logoutUrlBuilder.append("client_id=").append(clientId).append("&"); } - logoutUrlBuilder - .append("post_logout_redirect_uri=") - .append(URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8)); + String encodedRedirectUri = URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8); + logoutUrlBuilder.append("post_logout_redirect_uri=").append(encodedRedirectUri); String logoutUrl = logoutUrlBuilder.toString(); log.info("JWT-based OAuth2 logout URL: {}", logoutUrl); - response.sendRedirect(logoutUrl); + + // Return JSON for API requests, redirect for browser requests + if (isApi) { + sendJsonLogoutResponse(response, logoutUrl); + } else { + response.sendRedirect(logoutUrl); + } } else { // No OIDC logout endpoint available - fallback to local logout log.info( "No OIDC logout endpoint available for issuer: {}. Using local logout: {}", issuer, redirectUrl); - response.sendRedirect(redirectUrl); + if (isApi) { + sendJsonLogoutResponse(response, redirectUrl); + } else { + response.sendRedirect(redirectUrl); + } } } @@ -361,18 +368,21 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { log.debug("Using Keycloak fallback logout path: {}", endSessionEndpoint); } - // If we have an endpoint, construct the logout URL if (endSessionEndpoint != null) { StringBuilder logoutUrlBuilder = new StringBuilder(endSessionEndpoint); - // Extract id_token_hint if available (OIDC) + // Extract id_token_hint if available Object principal = oAuthToken.getPrincipal(); + if (principal instanceof OidcUser oidcUser) { String idToken = oidcUser.getIdToken().getTokenValue(); - logoutUrlBuilder.append( - endSessionEndpoint.contains("?") ? "&" : "?"); // Handle existing params - logoutUrlBuilder.append("id_token_hint=").append(idToken); logoutUrlBuilder + .append( + endSessionEndpoint.contains("?") + ? "&" + : "?") // Handle existing params + .append("id_token_hint=") + .append(idToken) .append("&post_logout_redirect_uri=") .append(URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8)); @@ -382,23 +392,20 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { logoutUrlBuilder.append("&client_id=").append(clientId); } - log.info("OIDC logout with id_token_hint (session-aware): {}", endSessionEndpoint); + log.info("Session-aware OIDC logout: {}", endSessionEndpoint); } else { - // Fallback to client_id only (less ideal, may show confirmation screen) logoutUrlBuilder.append(endSessionEndpoint.contains("?") ? "&" : "?"); if (clientId != null && !clientId.isBlank()) { - logoutUrlBuilder.append("client_id=").append(clientId); - logoutUrlBuilder.append("&"); + logoutUrlBuilder.append("client_id=").append(clientId).append("&"); } logoutUrlBuilder .append("post_logout_redirect_uri=") .append(URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8)); - - log.warn("OIDC logout without id_token_hint - user may see confirmation screen"); } String logoutUrl = logoutUrlBuilder.toString(); log.debug("OIDC logout URL: {}", logoutUrl); + response.sendRedirect(logoutUrl); } else { // No OIDC logout endpoint available - fallback to local logout @@ -410,6 +417,116 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { } } + /** + * Gets the OIDC end_session_endpoint from: 1. Configuration first 2. Fall back to discovery 3. + * Return null if not available + * + * @param oauth The OAuth2 configuration + * @param issuer The OIDC issuer URL + * @return The end_session_endpoint URL, or null if not available + */ + private String getEndSessionEndpoint( + ApplicationProperties.Security.OAUTH2 oauth, String issuer) { + if (oauth != null && oauth.getClient() != null) { + String configuredEndpoint = oauth.getClient().getEndSessionEndpoint(); + + if (configuredEndpoint != null && !configuredEndpoint.isBlank()) { + log.debug("Using configured end_session_endpoint: {}", configuredEndpoint); + return configuredEndpoint; + } + } + + if (issuer != null && !issuer.isBlank()) { + return discoverEndSessionEndpoint(issuer); + } + + return null; + } + + /** + * Discovers the OIDC end_session_endpoint from the provider's .well-known/openid-configuration + * Uses a cache to avoid repeated HTTP calls + * + * @param issuer The OIDC issuer URL + * @return The end_session_endpoint URL, or null if not found/supported + */ + private String discoverEndSessionEndpoint(String issuer) { + if (endSessionEndpointCache.containsKey(issuer)) { + return endSessionEndpointCache.get(issuer); + } + + try { + String discoveryUrl = issuer; + if (!discoveryUrl.endsWith("/")) { + discoveryUrl += "/"; + } + discoveryUrl += ".well-known/openid-configuration"; + + log.debug("Discovery URL: {}", discoveryUrl); + + // Make HTTP request with timeout using Spring's RestClient + RestClient restClient = + RestClient.builder() + .baseUrl(discoveryUrl) + .defaultHeaders(headers -> headers.set("Accept", "application/json")) + .build(); + + // Fetch and parse OIDC discovery document + Map discoveryDoc = + restClient + .get() + .retrieve() + .onStatus( + status -> !status.is2xxSuccessful(), + (request, response) -> + log.warn( + "Failed to discover OIDC endpoints for {}: HTTP status {}", + issuer, + response.getStatusCode().value())) + .body(Map.class); + + if (discoveryDoc != null && discoveryDoc.containsKey("end_session_endpoint")) { + String endpoint = (String) discoveryDoc.get("end_session_endpoint"); + if (endpoint != null && !endpoint.isBlank()) { + log.info("Discovered end_session_endpoint for {}: {}", issuer, endpoint); + // Cache the result + endSessionEndpointCache.put(issuer, endpoint); + return endpoint; + } + } + + log.info( + "Provider {} does not advertise end_session_endpoint in OIDC discovery", + issuer); + // Cache null result to avoid repeated failed attempts + endSessionEndpointCache.put(issuer, null); + return null; + + } catch (Exception e) { + log.warn("Error discovering end_session_endpoint for {}: {}", issuer, e.getMessage()); + return null; + } + } + + /** Check if the request expects a JSON response (API/XHR request) */ + private boolean isApiRequest(HttpServletRequest request) { + String accept = request.getHeader("Accept"); + String xRequestedWith = request.getHeader("X-Requested-With"); + return (accept != null && accept.contains("application/json")) + || "XMLHttpRequest".equals(xRequestedWith); + } + + /** Send JSON response with logout URL for API requests */ + private void sendJsonLogoutResponse(HttpServletResponse response, String logoutUrl) + throws IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + // Escape the URL for JSON + String escapedUrl = logoutUrl.replace("\\", "\\\\").replace("\"", "\\\""); + response.getWriter().write("{\"logoutUrl\":\"" + escapedUrl + "\"}"); + } + /** * Handles different error scenarios during logout. Will return a String containing * the error request parameter. @@ -458,100 +575,4 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { .matcher(input) .replaceAll(""); } - - /** - * Discovers the OIDC end_session_endpoint from the provider's .well-known/openid-configuration - * Uses a cache to avoid repeated HTTP calls - * - * @param issuer The OIDC issuer URL - * @return The end_session_endpoint URL, or null if not found/supported - */ - private String discoverEndSessionEndpoint(String issuer) { - // Check cache first - if (endSessionEndpointCache.containsKey(issuer)) { - return endSessionEndpointCache.get(issuer); - } - - try { - // Construct discovery URL - String discoveryUrl = issuer; - if (!discoveryUrl.endsWith("/")) { - discoveryUrl += "/"; - } - discoveryUrl += ".well-known/openid-configuration"; - - log.debug("Discovering OIDC endpoints from: {}", discoveryUrl); - - // Make HTTP request with timeout using Spring's RestClient - RestClient restClient = - RestClient.builder() - .baseUrl(discoveryUrl) - .defaultHeaders( - headers -> { - headers.set("Accept", "application/json"); - }) - .build(); - - // Fetch and parse OIDC discovery document - Map discoveryDoc = - restClient - .get() - .retrieve() - .onStatus( - status -> !status.is2xxSuccessful(), - (request, response) -> - log.warn( - "Failed to discover OIDC endpoints for {}: HTTP {}", - issuer, - response.getStatusCode().value())) - .body(Map.class); - - if (discoveryDoc != null && discoveryDoc.containsKey("end_session_endpoint")) { - String endpoint = (String) discoveryDoc.get("end_session_endpoint"); - if (endpoint != null && !endpoint.isBlank()) { - log.info("Discovered end_session_endpoint for {}: {}", issuer, endpoint); - // Cache the result - endSessionEndpointCache.put(issuer, endpoint); - return endpoint; - } - } - - log.info( - "Provider {} does not advertise end_session_endpoint in OIDC discovery", - issuer); - // Cache null result to avoid repeated failed attempts - endSessionEndpointCache.put(issuer, null); - return null; - - } catch (Exception e) { - log.warn("Error discovering end_session_endpoint for {}: {}", issuer, e.getMessage()); - return null; - } - } - - /** - * Gets the OIDC end_session_endpoint from: 1. Configuration first 2. Fall back to discovery 3. - * Return null if not available - * - * @param oauth The OAuth2 configuration - * @param issuer The OIDC issuer URL - * @return The end_session_endpoint URL, or null if not available - */ - private String getEndSessionEndpoint( - ApplicationProperties.Security.OAUTH2 oauth, String issuer) { - if (oauth != null && oauth.getClient() != null) { - String configuredEndpoint = oauth.getClient().getEndSessionEndpoint(); - - if (configuredEndpoint != null && !configuredEndpoint.isBlank()) { - log.debug("Using configured end_session_endpoint: {}", configuredEndpoint); - return configuredEndpoint; - } - } - - if (issuer != null && !issuer.isBlank()) { - return discoverEndSessionEndpoint(issuer); - } - - return null; - } } 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 fa561fc2a8..b5c6e32f6d 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 @@ -10,6 +10,8 @@ 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.security.cert.X509Certificate; import java.security.interfaces.RSAPrivateKey; import java.time.Instant; @@ -378,7 +380,6 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(response.encodeRedirectURL(anyString())).thenReturn(redirectUrl); when(securityProperties.getOauth2()).thenReturn(oauth); when(oauth.getClient()).thenReturn(client); @@ -405,7 +406,6 @@ class CustomLogoutSuccessHandlerTest { // Test that Keycloak logout without OidcUser falls back to client_id only 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); @@ -426,7 +426,6 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(response.encodeRedirectURL(anyString())).thenReturn(redirectUrl); when(securityProperties.getOauth2()).thenReturn(oauth); when(oauth.getClient()).thenReturn(client); @@ -451,7 +450,6 @@ class CustomLogoutSuccessHandlerTest { // Test that custom OAuth provider uses custom issuer URL String customIssuerUrl = "https://custom-oauth.example.com"; String clientId = "stirling-pdf"; - String redirectUrl = "http://localhost:8080/login?logout=true"; String idTokenValue = "custom.id.token"; HttpServletRequest request = mock(HttpServletRequest.class); @@ -480,7 +478,6 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(response.encodeRedirectURL(anyString())).thenReturn(redirectUrl); when(securityProperties.getOauth2()).thenReturn(oauth); when(oauth.getClient()).thenReturn(client); @@ -757,7 +754,6 @@ class CustomLogoutSuccessHandlerTest { String issuerUrl = "https://authentik.example.com/application/o/stirling-pdf/"; String clientId = "stirling-pdf"; String idTokenValue = "test.id.token"; - String redirectUrl = "http://localhost:8080/login?logout=true"; HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); @@ -784,7 +780,6 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(response.encodeRedirectURL(anyString())).thenReturn(redirectUrl); when(securityProperties.getOauth2()).thenReturn(oauth); when(oauth.getClient()).thenReturn(client); @@ -812,7 +807,6 @@ class CustomLogoutSuccessHandlerTest { String discoveredEndpoint = "https://authentik.example.com/application/o/end-session/"; String clientId = "stirling-pdf"; String idTokenValue = "test.id.token"; - String redirectUrl = "http://localhost:8080/login?logout=true"; HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); @@ -839,7 +833,6 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(response.encodeRedirectURL(anyString())).thenReturn(redirectUrl); when(securityProperties.getOauth2()).thenReturn(oauth); when(oauth.getClient()).thenReturn(client); @@ -1007,4 +1000,214 @@ class CustomLogoutSuccessHandlerTest { 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"; + + 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); + KeycloakProvider keycloakProvider = mock(KeycloakProvider.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(securityProperties.getOauth2()).thenReturn(oauth); + when(oauth.getClient()).thenReturn(client); + when(client.getEndSessionEndpoint()).thenReturn(null); + when(client.getKeycloak()).thenReturn(keycloakProvider); + when(keycloakProvider.getIssuer()).thenReturn(issuerUrl); + when(keycloakProvider.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"; + + 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); + KeycloakProvider keycloakProvider = mock(KeycloakProvider.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(securityProperties.getOauth2()).thenReturn(oauth); + when(oauth.getClient()).thenReturn(client); + when(client.getEndSessionEndpoint()).thenReturn(null); + when(client.getKeycloak()).thenReturn(keycloakProvider); + when(keycloakProvider.getIssuer()).thenReturn(issuerUrl); + when(keycloakProvider.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"; + + 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); + KeycloakProvider keycloakProvider = mock(KeycloakProvider.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"); // Browser request + when(request.getHeader("X-Requested-With")).thenReturn(null); + + when(jwtAuth.getToken()).thenReturn(jwt); + when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2")); + + when(securityProperties.getOauth2()).thenReturn(oauth); + when(oauth.getClient()).thenReturn(client); + when(client.getEndSessionEndpoint()).thenReturn(null); + when(client.getKeycloak()).thenReturn(keycloakProvider); + when(keycloakProvider.getIssuer()).thenReturn(issuerUrl); + when(keycloakProvider.getClientId()).thenReturn(clientId); + + customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth); + + // Verify redirect (not JSON) + verify(response).sendRedirect(contains(issuerUrl + "/protocol/openid-connect/logout")); + 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 + 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"); + 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(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 + + customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth); + + // Verify JSON response with local logout URL + verify(response).setStatus(HttpServletResponse.SC_OK); + verify(response).setContentType("application/json"); + + String jsonResponse = stringWriter.toString(); + assert jsonResponse.contains("\"logoutUrl\":"); + assert jsonResponse.contains("/login?logout=true"); + } }