Return JSON for API requests, redirect for browser requests

This commit is contained in:
Dario Ghunney Ware
2026-01-24 12:46:41 +00:00
committed by DarioGii
parent dba8c1f962
commit 9d895ae0ca
2 changed files with 350 additions and 126 deletions
@@ -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 <code>String</code> 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<String, Object> 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;
}
}
@@ -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");
}
}