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