Fixing SAML logout
This commit is contained in:
@@ -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}
|
||||
|
||||
+44
-106
@@ -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.
|
||||
|
||||
+40
-2
@@ -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.");
|
||||
|
||||
-23
@@ -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
|
||||
*
|
||||
|
||||
+18
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user