This commit is contained in:
Dario Ghunney Ware 2025-12-18 17:21:49 +00:00 committed by GitHub
commit abd93d4fab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1302 additions and 150 deletions

View File

@ -325,6 +325,7 @@ public class ApplicationProperties {
private GoogleProvider google = new GoogleProvider();
private GitHubProvider github = new GitHubProvider();
private KeycloakProvider keycloak = new KeycloakProvider();
private String endSessionEndpoint;
public Provider get(String registrationId) throws UnsupportedProviderException {
return switch (registrationId.toLowerCase()) {

View File

@ -160,7 +160,6 @@ public class RequestUriUtils {
|| trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)

View File

@ -40,6 +40,7 @@ security:
issuer: '' # set to any Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint
clientId: '' # client ID from your Provider
clientSecret: '' # client secret from your Provider
endSessionEndpoint: '' # Endpoint to initiate OIDC logout. Optional
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
useAsUsername: email # default is 'email'; custom fields can be used as the username

View File

@ -46,6 +46,7 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-oauth2-client'
api 'org.springframework.security:spring-security-oauth2-resource-server'
api 'org.springframework.boot:spring-boot-starter-mail'
api 'org.springframework.boot:spring-boot-starter-cache'
api 'com.github.ben-manes.caffeine:caffeine'

View File

@ -12,6 +12,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@ -371,6 +372,13 @@ public class ProprietaryUIDataController {
if (principal instanceof UserDetails detailsUser) {
username = detailsUser.getUsername();
} else if (principal instanceof Jwt jwt) {
username = jwt.getSubject();
switch (jwt.getClaimAsString("authType")) {
case "OAUTH2" -> isOAuth2Login = true;
case "SAML2" -> isSaml2Login = true;
}
} else if (principal instanceof OAuth2User oAuth2User) {
username = oAuth2User.getName();
isOAuth2Login = true;

View File

@ -1,17 +1,24 @@
package stirling.software.proprietary.security;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.core.io.Resource;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.web.client.RestClient;
import com.coveo.saml.SamlClient;
import com.coveo.saml.SamlException;
@ -42,6 +49,8 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
public static final String LOGOUT_PATH = "/login?logout=true";
private static final Map<String, String> endSessionEndpointCache = new ConcurrentHashMap<>();
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
@ -56,12 +65,13 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
if (!response.isCommitted()) {
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
// Handle SAML2 logout redirection
getRedirect_saml2(request, response, samlAuthentication);
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
// Handle OAuth2 logout redirection
getRedirect_oauth2(request, response, oAuthToken);
if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
getAuthRedirect(request, response, oAuthToken);
} else if (authentication instanceof Saml2Authentication samlAuthentication) {
getAuthRedirect(request, response, samlAuthentication);
} else if (authentication
instanceof JwtAuthenticationToken jwtAuthenticationToken) {
getAuthRedirect(request, response);
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
// Handle Username/Password logout
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
@ -88,7 +98,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
// Redirect for SAML2 authentication logout
private void getRedirect_saml2(
private void getAuthRedirect(
HttpServletRequest request,
HttpServletResponse response,
Saml2Authentication samlAuthentication)
@ -137,7 +147,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
// Redirect for OAuth2 authentication logout
private void getRedirect_oauth2(
private void getAuthRedirect(
HttpServletRequest request,
HttpServletResponse response,
OAuth2AuthenticationToken oAuthToken)
@ -151,50 +161,172 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
// Redirect based on OAuth2 provider
switch (registrationId.toLowerCase()) {
case "keycloak" -> {
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
boolean isKeycloak = !keycloak.getIssuer().isBlank();
boolean isCustomOAuth = !oauth.getIssuer().isBlank();
String logoutUrl = redirectUrl;
if (isKeycloak) {
logoutUrl = keycloak.getIssuer();
} else if (isCustomOAuth) {
logoutUrl = oauth.getIssuer();
}
if (isKeycloak || isCustomOAuth) {
logoutUrl +=
"/protocol/openid-connect/logout"
+ "?client_id="
+ oauth.getClientId()
+ "&post_logout_redirect_uri="
+ response.encodeRedirectURL(redirectUrl);
log.info("Redirecting to Keycloak logout URL: {}", logoutUrl);
} else {
log.info(
"No redirect URL for {} available. Redirecting to default logout URL:"
+ " {}",
registrationId,
logoutUrl);
}
response.sendRedirect(logoutUrl);
}
case "github", "google" -> {
// These providers don't support OIDC logout
log.info(
"No redirect URL for {} available. Redirecting to default logout URL: {}",
"No logout URL for {} available. Redirecting to local logout: {}",
registrationId,
redirectUrl);
response.sendRedirect(redirectUrl);
}
default -> {
log.info("Redirecting to default logout URL: {}", redirectUrl);
default -> handleOidcLogout(response, oAuthToken, oauth, redirectUrl);
}
}
// Redirect for JWT-based authentication logout
private void getAuthRedirect(HttpServletRequest request, HttpServletResponse response)
throws IOException {
OAUTH2 oauth = securityProperties.getOauth2();
String path = checkForErrors(request);
String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path;
boolean isApi = isApiRequest(request);
String issuer = null;
String clientId = null;
if (oauth.getClient() != null && oauth.getClient().getKeycloak() != null) {
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
if (keycloak.getIssuer() != null && !keycloak.getIssuer().isBlank()) {
issuer = keycloak.getIssuer();
clientId = keycloak.getClientId();
} else if (oauth.getIssuer() != null && !oauth.getIssuer().isBlank()) {
issuer = oauth.getIssuer();
clientId = oauth.getClientId();
}
} else if (oauth.getIssuer() != null && !oauth.getIssuer().isBlank()) {
issuer = oauth.getIssuer();
clientId = oauth.getClientId();
}
String endSessionEndpoint = getEndSessionEndpoint(oauth, issuer);
if (endSessionEndpoint == null && issuer != null) {
endSessionEndpoint = issuer + "/protocol/openid-connect/logout";
log.debug("Using Keycloak fallback logout path: {}", endSessionEndpoint);
}
if (endSessionEndpoint != null) {
StringBuilder logoutUrlBuilder = new StringBuilder(endSessionEndpoint);
logoutUrlBuilder.append(endSessionEndpoint.contains("?") ? "&" : "?");
// Use client_id and post_logout_redirect_uri
if (clientId != null && !clientId.isBlank()) {
logoutUrlBuilder.append("client_id=").append(clientId).append("&");
}
String encodedRedirectUri = URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8);
logoutUrlBuilder.append("post_logout_redirect_uri=").append(encodedRedirectUri);
String logoutUrl = logoutUrlBuilder.toString();
log.debug("JWT-based OAuth2 logout URL: {}", 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);
if (isApi) {
sendJsonLogoutResponse(response, redirectUrl);
} else {
response.sendRedirect(redirectUrl);
}
}
}
/**
* Handles OIDC logout with hybrid endpoint discovery Tries: 1. Configured endpoint 2.
* Discovered endpoint 3. Keycloak fallback (if isKeycloak=true) 4. Local logout
*/
private void handleOidcLogout(
HttpServletResponse response,
OAuth2AuthenticationToken oAuthToken,
OAUTH2 oauth,
String redirectUrl)
throws IOException {
String issuer = null;
String clientId = null;
boolean isKeycloak =
"keycloak".equalsIgnoreCase(oAuthToken.getAuthorizedClientRegistrationId());
if (isKeycloak) {
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
if (keycloak.getIssuer() != null && !keycloak.getIssuer().isBlank()) {
issuer = keycloak.getIssuer();
clientId = keycloak.getClientId();
} else if (oauth.getIssuer() != null && !oauth.getIssuer().isBlank()) {
issuer = oauth.getIssuer();
clientId = oauth.getClientId();
}
} else if (oauth.getIssuer() != null && !oauth.getIssuer().isBlank()) {
issuer = oauth.getIssuer();
clientId = oauth.getClientId();
}
String endSessionEndpoint = getEndSessionEndpoint(oauth, issuer);
// If no endpoint found and this is Keycloak, try the hardcoded path
if (endSessionEndpoint == null && isKeycloak && issuer != null) {
endSessionEndpoint = issuer + "/protocol/openid-connect/logout";
log.debug("Using Keycloak fallback logout path: {}", endSessionEndpoint);
}
if (endSessionEndpoint != null) {
StringBuilder logoutUrlBuilder = new StringBuilder(endSessionEndpoint);
// 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
.append("id_token_hint=")
.append(idToken)
.append("&post_logout_redirect_uri=")
.append(URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8));
// client_id is optional when id_token_hint is present, but included for
// compatibility
if (clientId != null && !clientId.isBlank()) {
logoutUrlBuilder.append("&client_id=").append(clientId);
}
log.info("Session-aware OIDC logout: {}", endSessionEndpoint);
} else {
logoutUrlBuilder.append(endSessionEndpoint.contains("?") ? "&" : "?");
if (clientId != null && !clientId.isBlank()) {
logoutUrlBuilder.append("client_id=").append(clientId).append("&");
}
logoutUrlBuilder
.append("post_logout_redirect_uri=")
.append(URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8));
}
String logoutUrl = logoutUrlBuilder.toString();
log.debug("OIDC logout URL: {}", logoutUrl);
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);
}
}
private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException {
@ -219,6 +351,114 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
SamlClient.SamlIdpBinding.POST);
}
/**
* 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);
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 : {}", endpoint);
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.

View File

@ -23,6 +23,7 @@ import org.springframework.security.saml2.provider.service.registration.RelyingP
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
@ -201,7 +202,7 @@ public class SecurityConfiguration {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
.addFilterBefore(jwtAuthenticationFilter, LogoutFilter.class);
http.sessionManagement(
sessionManagement ->

View File

@ -25,6 +25,7 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
@ -155,11 +156,30 @@ public class AuthController {
.body(Map.of("error", "Not authenticated"));
}
UserDetails userDetails = (UserDetails) auth.getPrincipal();
User user = (User) userDetails;
Object principal = auth.getPrincipal();
User user;
if (principal instanceof User u) {
user = u;
} else {
// JWT case - get User from Authority
user =
auth.getAuthorities().stream()
.filter(Authority.class::isInstance)
.map(Authority.class::cast)
.findFirst()
.map(Authority::getUser)
.orElseThrow(
() ->
new IllegalStateException(
"User not found in authentication"));
}
return ResponseEntity.ok(Map.of("user", buildUserResponse(user)));
} catch (IllegalStateException e) {
log.error("User not found in authentication context", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "User not found"));
} catch (Exception e) {
log.error("Get current user error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
@ -167,29 +187,6 @@ public class AuthController {
}
}
/**
* Logout endpoint
*
* @param response HTTP response
* @return Success message
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
try {
SecurityContextHolder.clearContext();
log.debug("User logged out successfully");
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
} catch (Exception e) {
log.error("Logout error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
}
}
/**
* Refresh token
*
@ -239,6 +236,7 @@ public class AuthController {
*/
private Map<String, Object> buildUserResponse(User user) {
Map<String, Object> userMap = new HashMap<>();
userMap.put("id", user.getId());
userMap.put("email", user.getUsername()); // Use username as email
userMap.put("username", user.getUsername());

View File

@ -8,25 +8,28 @@ import static stirling.software.proprietary.security.model.AuthenticationType.WE
import java.io.IOException;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@ -40,7 +43,6 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtServiceInterface jwtService;
@ -49,6 +51,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ApplicationProperties.Security securityProperties;
public JwtAuthenticationFilter(
JwtServiceInterface jwtService,
UserService userService,
CustomUserDetailsService userDetailsService,
AuthenticationEntryPoint authenticationEntryPoint,
ApplicationProperties.Security securityProperties) {
this.jwtService = jwtService;
this.userService = userService;
this.userDetailsService = userDetailsService;
this.authenticationEntryPoint = authenticationEntryPoint;
this.securityProperties = securityProperties;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@ -106,7 +121,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String tokenUsername = claims.get("sub").toString();
try {
authenticate(request, claims);
authenticate(request, jwtToken, claims);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
handleAuthenticationFailure(
@ -160,7 +175,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return true;
}
private void authenticate(HttpServletRequest request, Map<String, Object> claims)
private void authenticate(
HttpServletRequest request, String jwtToken, Map<String, Object> claims)
throws SQLException, UnsupportedProviderException {
String username = claims.get("sub").toString();
@ -169,9 +185,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails != null) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
convertTimestampToLong(claims, "iat");
convertTimestampToLong(claims, "exp");
convertTimestampToLong(claims, "nbf");
Jwt jwt =
Jwt.withTokenValue(jwtToken)
.headers(headers -> headers.put("alg", Jwts.SIG.RS256.getId()))
.claims(claimsMap -> claimsMap.putAll(claims))
.build();
JwtAuthenticationToken authToken =
new JwtAuthenticationToken(jwt, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
@ -208,6 +233,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
}
private void convertTimestampToLong(Map<String, Object> claims, String claimName) {
Long timestamp = (Long) claims.get(claimName);
claims.put(claimName, Instant.ofEpochSecond(timestamp));
}
private void handleAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,

View File

@ -36,6 +36,7 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@ -74,8 +75,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
// Check if user is eligible for OAuth (grandfathered or system has paid license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
User user = userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block OAuth login
@ -154,7 +154,6 @@ public class CustomOAuth2AuthenticationSuccessHandler
OAUTH2);
}
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(

View File

@ -79,13 +79,14 @@ public class JwtService implements JwtServiceInterface {
}
KeyPair keyPair = keyPairOpt.get();
Date now = new Date();
var builder =
Jwts.builder()
.claims(claims)
.subject(username)
.issuer(ISSUER)
.issuedAt(new Date())
.issuedAt(now)
.notBefore(now)
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
@ -251,14 +252,10 @@ public class JwtService implements JwtServiceInterface {
@Override
public String extractToken(HttpServletRequest request) {
// Extract from Authorization header Bearer token
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); // Remove "Bearer " prefix
return token;
}
return null;
return (authHeader != null && authHeader.startsWith("Bearer "))
? authHeader.substring(7)
: null;
}
@Override

View File

@ -1,23 +1,46 @@
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.lenient;
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.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.time.Instant;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
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;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.proprietary.security.saml2.CertificateUtils;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@ExtendWith(MockitoExtension.class)
@ -81,6 +104,7 @@ class CustomLogoutSuccessHandlerTest {
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);
@ -112,6 +136,7 @@ class CustomLogoutSuccessHandlerTest {
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);
@ -137,6 +162,7 @@ class CustomLogoutSuccessHandlerTest {
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);
@ -162,6 +188,7 @@ class CustomLogoutSuccessHandlerTest {
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);
@ -189,6 +216,7 @@ class CustomLogoutSuccessHandlerTest {
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);
@ -221,6 +249,7 @@ class CustomLogoutSuccessHandlerTest {
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);
@ -254,6 +283,7 @@ class CustomLogoutSuccessHandlerTest {
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);
@ -281,10 +311,876 @@ class CustomLogoutSuccessHandlerTest {
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<CertificateUtils> 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<CertificateUtils> 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<CertificateUtils> 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<CertificateUtils> 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<RestClient> 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<String, Object> 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<RestClient> 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<String, Object> 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()
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<RestClient> 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";
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);
lenient().when(jwtAuth.getToken()).thenReturn(jwt);
lenient().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);
lenient().when(jwtAuth.getToken()).thenReturn(jwt);
lenient().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);
lenient().when(jwtAuth.getToken()).thenReturn(jwt);
lenient().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);
lenient().when(jwtAuth.getToken()).thenReturn(jwt);
lenient().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");
}
}

View File

@ -457,7 +457,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -501,7 +500,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -582,7 +580,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.5.0",
"@embedpdf/models": "1.5.0"
@ -682,7 +679,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -699,7 +695,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -717,7 +712,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -771,7 +765,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -806,7 +799,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -843,7 +835,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -919,7 +910,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -1075,7 +1065,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1119,7 +1108,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -2150,7 +2138,6 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@ -2201,7 +2188,6 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@ -2269,7 +2255,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@ -3202,7 +3187,6 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@ -3321,6 +3305,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@ -4097,7 +4082,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -4426,7 +4410,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -4437,7 +4420,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -4507,7 +4489,6 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@ -5221,6 +5202,7 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@ -5230,6 +5212,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@ -5240,6 +5223,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@ -5252,6 +5236,7 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@ -5278,7 +5263,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5686,6 +5670,7 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@ -5962,7 +5947,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@ -7010,8 +6994,7 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/dezalgo": {
"version": "1.0.4",
@ -7406,7 +7389,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -7577,7 +7559,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -7744,7 +7725,8 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/espree": {
"version": "10.4.0",
@ -7809,6 +7791,7 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@ -8899,7 +8882,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@ -9376,6 +9358,7 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@ -9696,7 +9679,6 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@ -10283,7 +10265,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/locate-path": {
"version": "6.0.0",
@ -11442,7 +11425,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -11722,7 +11704,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -12105,7 +12086,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -12115,7 +12095,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -13627,6 +13606,7 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@ -13835,7 +13815,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14137,7 +14116,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -14219,7 +14197,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@ -14424,7 +14401,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -14595,7 +14571,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14609,7 +14584,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@ -15221,7 +15195,8 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/zod": {
"version": "3.25.76",

View File

@ -259,7 +259,7 @@ describe('SpringAuthClient', () => {
const result = await springAuth.signOut();
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/auth/logout',
'/logout',
null,
expect.objectContaining({ withCredentials: true })
);

View File

@ -280,31 +280,39 @@ class SpringAuthClient {
/**
* Sign out user (invalidate session)
* Uses Spring Security's logout endpoint which handles OAuth2/SAML logout redirects
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
const response = await apiClient.post('/api/v1/auth/logout', null, {
// Extract token before making request (needed for OAuth/SAML logout)
const token = localStorage.getItem('stirling_jwt');
const response = await apiClient.post('/logout', null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
'Accept': 'application/json', // Request JSON response for SPA
},
withCredentials: true,
});
if (response.status === 200) {
// console.debug('[SpringAuth] signOut: Success');
}
// Clean up local storage
// Clean up local storage after successful logout request
localStorage.removeItem('stirling_jwt');
// Notify listeners
this.notifyListeners('SIGNED_OUT', null);
// If we got a logout URL (for OIDC/OAuth2 providers), redirect immediately
// Don't notify listeners to avoid React re-render before redirect
if (response.data?.logoutUrl) {
window.location.href = response.data.logoutUrl;
return { error: null };
}
this.notifyListeners('SIGNED_OUT', null);
window.location.href = '/login?logout=true';
return { error: null };
} catch (error: unknown) {
console.error('[SpringAuth] signOut error:', error);
// Still remove token even if backend call fails
localStorage.removeItem('stirling_jwt');
this.notifyListeners('SIGNED_OUT', null);
window.location.href = '/login?logout=true';
return {
error: { message: getErrorMessage(error, 'Logout failed') },
};

View File

@ -37,17 +37,9 @@ const AccountSection: React.FC = () => {
const userIdentifier = useMemo(() => user?.email || user?.username || '', [user?.email, user?.username]);
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
}, []);
const handleLogout = useCallback(async () => {
try {
await signOut();
} finally {
redirectToLogin();
}
}, [redirectToLogin, signOut]);
await signOut();
}, [signOut]);
const handlePasswordSubmit = async (event: React.FormEvent) => {
event.preventDefault();

View File

@ -85,6 +85,12 @@ export default defineConfig(({ mode }) => {
secure: false,
xfwd: true,
},
'/logout': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
},
},
base: process.env.RUN_SUBPATH ? `/${process.env.RUN_SUBPATH}` : './',