Fixing SAML logout

This commit is contained in:
Dario Ghunney Ware
2026-01-26 11:07:38 +00:00
parent 4492fa39be
commit 1b737cbce5
12 changed files with 210 additions and 158 deletions
@@ -227,6 +227,7 @@ public class ApplicationProperties {
private String idpSingleLogoutUrl;
private String idpSingleLoginUrl;
private String idpIssuer;
private Boolean enableSingleLogout = false;
@JsonProperty("idpCert")
private String idpCert;
@@ -165,7 +165,7 @@ 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("/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
@@ -52,10 +52,11 @@ security:
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs
idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata
idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider
idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider
idpIssuer: "" # The ID of your Provider
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your IdP
enableSingleLogout: true # set to 'true' to enable Single Logout (SP-initiated SLO). Logs the user out from the IdP
idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your IdP
idpIssuer: "" # The ID of your IdP
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your IdP
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
@@ -9,47 +9,57 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
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.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import com.coveo.saml.SamlClient;
import com.coveo.saml.SamlException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.UrlUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.saml2.CertificateUtils;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Slf4j
@RequiredArgsConstructor
public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
public static final String LOGOUT_PATH = "/login?logout=true";
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final JwtServiceInterface jwtService;
private final LogoutSuccessHandler samlLogoutHandler;
public CustomLogoutSuccessHandler(
ApplicationProperties.Security securityProperties,
AppConfig appConfig,
JwtServiceInterface jwtService) {
this(securityProperties, appConfig, jwtService, null);
}
public CustomLogoutSuccessHandler(
ApplicationProperties.Security securityProperties,
AppConfig appConfig,
JwtServiceInterface jwtService,
LogoutSuccessHandler samlLogoutHandler) {
this.securityProperties = securityProperties;
this.appConfig = appConfig;
this.jwtService = jwtService;
this.samlLogoutHandler = samlLogoutHandler;
}
@Override
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
@@ -58,16 +68,29 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
throws IOException {
if (!response.isCommitted()) {
// Handle SAML2 SLO
Optional<String> samlNameId = resolveSamlNameId(authentication, request);
if (samlNameId.isPresent()) {
getRedirect_saml2(request, response, samlNameId.get());
if (samlLogoutHandler != null) {
log.info("SAML user {} logging out via IdP SLO", samlNameId.get());
try {
samlLogoutHandler.onLogoutSuccess(request, response, authentication);
} catch (Exception e) {
log.error("SAML SLO failed, falling back to local logout", e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else {
// SAML Single Logout disabled - just do local logout
log.info("SAML user {} logging out locally (SLO disabled)", samlNameId.get());
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
return;
}
if (authentication != null) {
if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
// Handle OAuth2 logout redirection
getRedirect_oauth2(request, response, oAuthToken);
getRedirectOauth2(request, response, oAuthToken);
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
// Handle Username/Password logout
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
@@ -79,82 +102,20 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else {
if (jwtService != null) {
String token = jwtService.extractToken(request);
if (token != null && !token.isBlank()) {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
return;
}
}
// Redirect to login page after logout
String path = checkForErrors(request);
getRedirectStrategy().sendRedirect(request, response, path);
// Redirect to login page after logout (handles error parameters if present)
String queryParams = checkForErrors(request);
getRedirectStrategy().sendRedirect(request, response, "/login?" + queryParams);
}
}
}
// Redirect for SAML2 authentication logout
private void getRedirect_saml2(
HttpServletRequest request, HttpServletResponse response, Saml2Authentication samlAuth)
throws IOException {
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuth.getPrincipal();
getRedirect_saml2(request, response, principal.nameId());
}
private void getRedirect_saml2(
HttpServletRequest request, HttpServletResponse response, String nameIdValue)
throws IOException {
if (nameIdValue == null || nameIdValue.isBlank()) {
log.warn("Unable to perform SAML logout due to missing NameID");
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
return;
}
SAML2 samlConf = securityProperties.getSaml2();
String registrationId = samlConf.getRegistrationId();
try {
// Read certificate from the resource
Resource certificateResource = samlConf.getSpCert();
X509Certificate certificate = CertificateUtils.readCertificate(certificateResource);
List<X509Certificate> certificates = new ArrayList<>();
certificates.add(certificate);
// Construct URLs required for SAML configuration
SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates);
// Read private key for service provider
Resource privateKeyResource = samlConf.getPrivateKey();
RSAPrivateKey privateKey = CertificateUtils.readPrivateKey(privateKeyResource);
// Set service provider keys for the SamlClient
samlClient.setSPKeys(certificate, privateKey);
// Build relay state to return user to login page after IdP logout
String relayState =
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
// Redirect to identity provider for logout with relay state
samlClient.redirectToIdentityProvider(response, relayState, nameIdValue);
} catch (Exception e) {
log.error(
"Error retrieving logout URL from Provider {} for user {}",
samlConf.getIdpIssuer(),
nameIdValue,
e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
}
private Optional<String> resolveSamlNameId(
Authentication authentication, HttpServletRequest request) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
String nameId = principal.nameId();
if (nameId != null && !nameId.isBlank()) {
return Optional.of(nameId);
}
@@ -163,14 +124,15 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
if (jwtService != null) {
try {
String token = jwtService.extractToken(request);
if (token != null && !token.isBlank()) {
Map<String, Object> claims = jwtService.extractClaims(token);
Object authType = claims.get("authType");
if (authType != null && "SAML2".equalsIgnoreCase(authType.toString())) {
Object nameId = claims.get("samlNameId");
if (nameId instanceof String && !((String) nameId).isBlank()) {
return Optional.of((String) nameId);
}
log.debug("Resolved SAML NameID from JWT: {}", nameId);
return Optional.of((String) nameId);
}
}
} catch (Exception ex) {
@@ -182,7 +144,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
// Redirect for OAuth2 authentication logout
private void getRedirect_oauth2(
private void getRedirectOauth2(
HttpServletRequest request,
HttpServletResponse response,
OAuth2AuthenticationToken oAuthToken)
@@ -240,30 +202,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
}
private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException {
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId;
String idpSLOUrl = samlConf.getIdpSingleLogoutUrl();
String idpIssuer = samlConf.getIdpIssuer();
// Create SamlClient instance for SAML logout
return new SamlClient(
relyingPartyIdentifier,
assertionConsumerServiceUrl,
idpSLOUrl,
idpIssuer,
certificates,
SamlClient.SamlIdpBinding.POST);
}
/**
* Handles different error scenarios during logout. Will return a <code>String</code> containing
* the error request parameter.
@@ -20,11 +20,14 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.authentication.logout.OpenSaml4LogoutRequestResolver;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2RelyingPartyInitiatedLogoutSuccessHandler;
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.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
@@ -227,6 +230,20 @@ public class SecurityConfiguration {
return requestURI.startsWith(contextPath + "/api/");
}));
// Create SAML logout handler if SAML SLO is enabled
final LogoutSuccessHandler samlLogoutHandler;
if (securityProperties.isSaml2Active()
&& Boolean.TRUE.equals(securityProperties.getSaml2().getEnableSingleLogout())
&& saml2RelyingPartyRegistrations != null) {
log.info("Creating SAML2 SLO handler for SP-initiated logout");
OpenSaml4LogoutRequestResolver logoutRequestResolver =
new OpenSaml4LogoutRequestResolver(saml2RelyingPartyRegistrations);
samlLogoutHandler =
new Saml2RelyingPartyInitiatedLogoutSuccessHandler(logoutRequestResolver);
} else {
samlLogoutHandler = null;
}
http.logout(
logout ->
logout.logoutRequestMatcher(
@@ -234,10 +251,17 @@ public class SecurityConfiguration {
.matcher("/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
securityProperties, appConfig, jwtService))
securityProperties,
appConfig,
jwtService,
samlLogoutHandler))
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
.deleteCookies(
"JSESSIONID",
"remember-me",
"stirling_jwt",
"stirling_logout_token"));
http.rememberMe(
rememberMeConfigurer -> // Use the configurator directly
rememberMeConfigurer
@@ -361,6 +385,20 @@ public class SecurityConfiguration {
}
})
.saml2Metadata(metadata -> {});
// Configure SAML2 Single Logout if enabled
// This sets up endpoints for:
// - IdP-initiated logout: IdP sends LogoutRequest to /logout/saml2/slo
// - SP-initiated logout response: IdP sends LogoutResponse to /logout/saml2/slo
if (Boolean.TRUE.equals(securityProperties.getSaml2().getEnableSingleLogout())) {
log.info("SAML2 Single Logout (SLO) is enabled - configuring SLO endpoints");
http.saml2Logout(
logout ->
logout.logoutUrl(
"/logout/saml2/slo") // URL that handles both request
// and response
);
}
}
} else {
log.debug("Login is not enabled.");
@@ -231,29 +231,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
*
@@ -258,6 +258,24 @@ public class JwtService implements JwtServiceInterface {
return token;
}
// Check for logout cookie (set by frontend before redirecting to /logout for SAML SLO)
if (request.getCookies() != null) {
for (jakarta.servlet.http.Cookie cookie : request.getCookies()) {
if ("stirling_logout_token".equals(cookie.getName())) {
String value = cookie.getValue();
if (value != null && !value.isBlank()) {
try {
return java.net.URLDecoder.decode(
value, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
log.debug("Failed to decode logout token cookie", e);
return value;
}
}
}
}
}
return null;
}
@@ -0,0 +1,59 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0.69 5.4 400 390.62">
<g clip-path="url(#clip0_10641_164)">
<path d="M0.685165 65.8523C0.685165 14.7648 67.4855 -13.2029 111.616 19.4083L207.551 90.2535L288.514 44.373C331.305 21.0394 399.874 37.7748 400.685 101.559L400.685 336.622C400.685 387.71 335.845 414.343 291.715 381.732L194.031 309.34L112.973 355.23C69.7783 378.784 0.752625 360.714 0.752625 298.637C0.752654 224.124 0.685165 252.734 0.685165 65.8523Z" fill="url(#paint0_linear_10641_164)"/>
<path d="M0.685165 65.8523C0.685165 14.7648 67.4855 -13.2029 111.616 19.4083L207.551 90.2535L288.514 44.373C331.305 21.0394 399.874 37.7748 400.685 101.559L400.685 336.622C400.685 387.71 335.845 414.343 291.715 381.732L194.031 309.34L112.973 355.23C69.7783 378.784 0.752625 360.714 0.752625 298.637C0.752654 224.124 0.685165 252.734 0.685165 65.8523Z" fill="url(#paint1_radial_10641_164)"/>
<path d="M0.685165 65.8523C0.685165 14.7648 67.4855 -13.2029 111.616 19.4083L207.551 90.2535L288.514 44.373C331.305 21.0394 399.874 37.7748 400.685 101.559L400.685 336.622C400.685 387.71 335.845 414.343 291.715 381.732L194.031 309.34L112.973 355.23C69.7783 378.784 0.752625 360.714 0.752625 298.637C0.752654 224.124 0.685165 252.734 0.685165 65.8523Z" fill="url(#paint2_radial_10641_164)"/>
<path d="M111.616 19.4083C67.4855 -13.2029 0.685165 14.7648 0.685165 65.8523V295.742C0.685165 290.153 3.5327 279.026 3.5327 279.026C16.6954 236.001 72.6487 219.869 111.36 248.223L291.715 381.732C335.845 414.343 400.685 387.71 400.685 336.622V101.559C400.685 145.201 343.643 189.011 289.755 151.051L111.616 19.4083Z" fill="url(#paint3_radial_10641_164)"/>
<path d="M111.616 19.4083C67.4855 -13.2029 0.685165 14.7648 0.685165 65.8523V295.742C0.685165 290.153 3.5327 279.026 3.5327 279.026C16.6954 236.001 72.6487 219.869 111.36 248.223L291.715 381.732C335.845 414.343 400.685 387.71 400.685 336.622V101.559C400.685 145.201 343.643 189.011 289.755 151.051L111.616 19.4083Z" fill="url(#paint4_radial_10641_164)" fill-opacity="0.25"/>
<path d="M111.616 19.4083C67.4855 -13.2029 0.685165 14.7648 0.685165 65.8523V295.742C0.685165 290.153 3.5327 279.026 3.5327 279.026C16.6954 236.001 72.6487 219.869 111.36 248.223L291.715 381.732C335.845 414.343 400.685 387.71 400.685 336.622V101.559C400.685 145.201 343.643 189.011 289.755 151.051L111.616 19.4083Z" fill="url(#paint5_radial_10641_164)" fill-opacity="0.55"/>
<path d="M111.616 149.691C67.4855 117.08 0.685165 145.048 0.685165 196.135L0.685179 65.8523C0.685183 14.7648 67.4855 -13.2029 111.616 19.4083C170.996 63.2888 230.374 107.171 289.755 151.051C343.643 189.011 400.685 145.201 400.685 101.559V234.89C400.685 285.978 333.885 313.946 289.755 281.334C230.374 237.454 170.996 193.572 111.616 149.691Z" fill="url(#paint6_linear_10641_164)"/>
<path d="M111.616 149.691C67.4855 117.08 0.685165 145.048 0.685165 196.135L0.685179 65.8523C0.685183 14.7648 67.4855 -13.2029 111.616 19.4083C170.996 63.2888 230.374 107.171 289.755 151.051C343.643 189.011 400.685 145.201 400.685 101.559V234.89C400.685 285.978 333.885 313.946 289.755 281.334C230.374 237.454 170.996 193.572 111.616 149.691Z" fill="url(#paint7_radial_10641_164)" fill-opacity="0.35"/>
</g>
<defs>
<linearGradient id="paint0_linear_10641_164" x1="171.501" y1="396.026" x2="314.256" y2="90.5646" gradientUnits="userSpaceOnUse">
<stop offset="0.02" stop-color="#1169DA"/>
<stop offset="0.434784" stop-color="#0151BD"/>
<stop offset="0.614436" stop-color="#014DB7"/>
<stop offset="1" stop-color="#126AD9"/>
</linearGradient>
<radialGradient id="paint1_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(358.656 -48.704) rotate(110.224) scale(292.248 218.917)">
<stop offset="0.422966" stop-color="#004AFF" stop-opacity="0.1"/>
<stop offset="0.728672" stop-color="#014DB9"/>
<stop offset="0.836135" stop-color="#014DB9" stop-opacity="0.9"/>
<stop offset="0.955" stop-color="#014DB9" stop-opacity="0"/>
</radialGradient>
<radialGradient id="paint2_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(54.0821 384.087) rotate(-73.6432) scale(232.668 283.419)">
<stop offset="0.0908532" stop-color="#004AFF" stop-opacity="0.1"/>
<stop offset="0.56036" stop-color="#014DB9"/>
<stop offset="0.749466" stop-color="#014DB9" stop-opacity="0.99"/>
<stop offset="1" stop-color="#014DB9" stop-opacity="0"/>
</radialGradient>
<radialGradient id="paint3_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(439.444 5.40101) rotate(134.068) scale(563.957 434.374)">
<stop offset="0.249322" stop-color="#23C0FE"/>
<stop offset="0.717207" stop-color="#23C0FE"/>
<stop offset="0.995168" stop-color="#1C91FF"/>
</radialGradient>
<radialGradient id="paint4_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(119.961 37.1103) rotate(45.9092) scale(403.457 95.9789)">
<stop offset="0.165" stop-color="#096DD6" stop-opacity="0"/>
<stop offset="0.484233" stop-color="#096DD6"/>
<stop offset="0.900505" stop-color="#0876DE" stop-opacity="0.813868"/>
<stop offset="1" stop-color="#029AFF" stop-opacity="0"/>
</radialGradient>
<radialGradient id="paint5_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(385.776 245.022) rotate(91.6099) scale(64.799 58.3555)">
<stop stop-color="#0068B3"/>
<stop offset="0.93" stop-color="#006CB8" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint6_linear_10641_164" x1="114.72" y1="97.5227" x2="182.067" y2="383.699" gradientUnits="userSpaceOnUse">
<stop stop-color="#92EEFE"/>
<stop offset="0.564559" stop-color="#35DDFF"/>
<stop offset="1" stop-color="#08B1F9"/>
</linearGradient>
<radialGradient id="paint7_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(67.7807 5.4011) rotate(69.461) scale(99.6637 80.4988)">
<stop stop-color="#CCF9FF" stop-opacity="0.93"/>
<stop offset="1" stop-color="#35DDFF"/>
</radialGradient>
<clipPath id="clip0_10641_164">
<rect width="400" height="400" fill="white" transform="translate(0.685181 0.713501)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

@@ -309,18 +309,24 @@ class SpringAuthClient {
/**
* Sign out user (invalidate session)
*
* For SAML/OAuth users, this navigates to /logout which triggers
* Spring Security's logout handler and initiates SSO logout.
* The JWT is passed via a short-lived cookie so the backend can
* extract the SAML NameID for single logout (SLO).
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
const response = await apiClient.post('/api/v1/auth/logout', null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
// Get the JWT before removing it - we need to pass it to the backend
// so it can extract the SAML NameID for single logout (SLO)
const token = localStorage.getItem('stirling_jwt');
if (response.status === 200) {
// console.debug('[SpringAuth] signOut: Success');
// Store JWT in a short-lived cookie for the logout request
// This avoids exposing the token in URL params while ensuring
// it's sent with the redirect request
if (token) {
// Cookie expires in 30 seconds - just long enough for the logout redirect
document.cookie = `stirling_logout_token=${encodeURIComponent(token)}; path=/; max-age=30; SameSite=Lax`;
}
// Clean up local storage
@@ -355,13 +361,18 @@ class SpringAuthClient {
console.warn('[SpringAuth] Failed to run platform auth cleanup', cleanupError);
}
// Notify listeners
// Notify listeners before redirect
this.notifyListeners('SIGNED_OUT', null);
// Navigate to /logout to trigger Spring Security's logout handler
console.log('[SpringAuth] Redirecting to /logout for session invalidation');
window.location.href = `${BASE_PATH}/logout`;
// This won't be reached if redirect happens, but return for type safety
return { error: null };
} catch (error: unknown) {
console.error('[SpringAuth] signOut error:', error);
// Still remove token even if backend call fails
// Still remove token even if the call fails
localStorage.removeItem('stirling_jwt');
try {
await clearPlatformAuthAfterSignOut();
@@ -1,20 +1,16 @@
import { Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { useNavigate } from 'react-router-dom';
export function OverviewHeader() {
const { t } = useTranslation();
const { signOut, user } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await signOut();
navigate('/login');
} catch (error) {
console.error('Logout error:', error);
}
console.log('[OverviewHeader] Logout button clicked, calling signOut()');
// signOut() handles navigation to /logout for SAML/OAuth SLO
// Do NOT navigate after signOut - it will redirect the page
await signOut();
};
return (
@@ -53,11 +53,12 @@ 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 () => {
console.log('[AccountSection] Logout button clicked, calling signOut()');
// signOut() handles navigation to /logout for SAML/OAuth SLO
// Do NOT navigate after signOut - it will redirect the page
await signOut();
}, [signOut]);
await accountLogout({ signOut, redirectToLogin });
}, [accountLogout, redirectToLogin, signOut]);
+12
View File
@@ -78,6 +78,18 @@ export default defineConfig(({ mode }) => {
secure: false,
xfwd: true,
},
'/logout': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
'/logout/saml2': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
'/swagger-ui': {
target: 'http://localhost:8080',
changeOrigin: true,