Implemented SP-initiated SLO enabled with flag

# Conflicts:
#	frontend/src/proprietary/routes/Login.tsx
This commit is contained in:
Dario Ghunney Ware
2026-01-26 11:07:38 +00:00
parent 1b737cbce5
commit 70c9cb36c3
8 changed files with 199 additions and 64 deletions
@@ -6,11 +6,13 @@ import java.security.interfaces.RSAPrivateKey;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Collections;
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.authority.SimpleGrantedAuthority;
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;
@@ -68,22 +70,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
throws IOException {
if (!response.isCommitted()) {
// Handle SAML2 SLO
Optional<String> samlNameId = resolveSamlNameId(authentication, request);
if (samlNameId.isPresent()) {
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);
}
if (handleSamlLogout(request, response, authentication)) {
return;
}
@@ -109,38 +96,126 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
}
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);
}
}
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");
log.debug("Resolved SAML NameID from JWT: {}", nameId);
return Optional.of((String) nameId);
}
/**
* Handles SAML logout - either via IdP Single Logout (SLO) or local logout.
*
* @return true if this was a SAML user and logout was handled, false otherwise
*/
private boolean handleSamlLogout(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
if (securityProperties.getSaml2().getEnableSingleLogout()) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
String nameId = principal.nameId();
log.info("SAML user {} logging out via IdP SLO (session-based)", nameId);
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);
}
} catch (Exception ex) {
log.debug("Unable to resolve SAML NameID from JWT during logout", ex);
return true;
}
// Try to reconstruct Saml2Authentication from JWT claims for SLO
Optional<Saml2Authentication> reconstructedAuth =
reconstructSaml2AuthenticationFromJwt(request);
if (reconstructedAuth.isPresent()) {
Saml2Authentication samlAuth = reconstructedAuth.get();
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuth.getPrincipal();
log.info(
"SAML user {} logging out via IdP SLO (reconstructed from JWT)",
principal.nameId());
try {
samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
} catch (Exception e) {
log.error("SAML SLO failed, falling back to local logout", e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
return true;
}
} else {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
return true;
}
return Optional.empty();
return false;
}
/**
* Reconstructs a Saml2Authentication from JWT claims for SAML Single Logout. This allows SLO to
* work even with stateless JWT sessions by extracting the SAML attributes that were stored in
* the JWT during initial authentication.
*/
@SuppressWarnings("unchecked")
private Optional<Saml2Authentication> reconstructSaml2AuthenticationFromJwt(
HttpServletRequest request) {
try {
String token = jwtService.extractToken(request);
if (token == null || token.isBlank()) {
return Optional.empty();
}
Map<String, Object> claims = jwtService.extractClaims(token);
Object authType = claims.get("authType");
if (authType == null || !"SAML2".equalsIgnoreCase(authType.toString())) {
return Optional.empty();
}
// Extract SAML claims from JWT
String username = (String) claims.get("sub");
String nameId = (String) claims.get("samlNameId");
String registrationId = (String) claims.get("samlRegistrationId");
Object sessionIndexesObj = claims.get("samlSessionIndexes");
if (nameId == null || registrationId == null) {
log.debug(
"Missing required SAML claims for SLO reconstruction: nameId={}, registrationId={}",
nameId,
registrationId);
return Optional.empty();
}
// Parse session indexes (stored as List in JWT)
List<String> sessionIndexes = Collections.emptyList();
if (sessionIndexesObj instanceof List<?>) {
sessionIndexes =
((List<?>) sessionIndexesObj).stream().map(Object::toString).toList();
}
// Create principal with all SAML attributes needed for SLO
CustomSaml2AuthenticatedPrincipal principal =
new CustomSaml2AuthenticatedPrincipal(
username,
Collections.emptyMap(), // Attributes not needed for logout
nameId,
sessionIndexes,
registrationId);
// Create Saml2Authentication with the reconstructed principal
// The saml2Response parameter is not used by the logout handler, but constructor
// requires non-empty value, so we provide a placeholder
Saml2Authentication samlAuth =
new Saml2Authentication(
principal,
"<!-- reconstructed for logout -->",
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
log.debug(
"Reconstructed Saml2Authentication from JWT for user {} with registrationId {}",
username,
registrationId);
return Optional.of(samlAuth);
} catch (Exception ex) {
log.debug("Unable to reconstruct Saml2Authentication from JWT", ex);
return Optional.empty();
}
}
// Redirect for OAuth2 authentication logout
@@ -175,7 +250,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
logoutUrl +=
"/protocol/openid-connect/logout"
+ "?client_id="
+ oauth.getClientId()
+ keycloak.getClientId()
+ "&post_logout_redirect_uri="
+ response.encodeRedirectURL(redirectUrl);
log.info("Redirecting to Keycloak logout URL: {}", logoutUrl);
@@ -20,9 +20,9 @@ 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.OpenSaml4LogoutRequestResolver;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2RelyingPartyInitiatedLogoutSuccessHandler;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@@ -12,9 +12,22 @@ public record CustomSaml2AuthenticatedPrincipal(
String name,
Map<String, List<Object>> attributes,
String nameId,
List<String> sessionIndexes)
List<String> sessionIndexes,
String relyingPartyRegistrationId)
implements Saml2AuthenticatedPrincipal, Serializable {
/**
* Constructor without relyingPartyRegistrationId for backwards compatibility. Sets
* relyingPartyRegistrationId to null.
*/
public CustomSaml2AuthenticatedPrincipal(
String name,
Map<String, List<Object>> attributes,
String nameId,
List<String> sessionIndexes) {
this(name, attributes, nameId, sessionIndexes, null);
}
@Override
public String getName() {
return this.name;
@@ -24,4 +37,14 @@ public record CustomSaml2AuthenticatedPrincipal(
public Map<String, List<Object>> getAttributes() {
return this.attributes;
}
@Override
public List<String> getSessionIndexes() {
return this.sessionIndexes;
}
@Override
public String getRelyingPartyRegistrationId() {
return this.relyingPartyRegistrationId;
}
}

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" fill="#fff"/><path d="M268.6 102.4c64.4 0 116.8 52.4 116.8 116.7 0 25.3-8 49.4-23 69.6-14.8 19.9-35 34.3-58.4 41.7l-6.5 2-15.5-76.2 4.3-2c14-6.7 23-21.1 23-36.6 0-22.4-18.2-40.6-40.6-40.6S228 195.2 228 217.6c0 15.5 9 29.8 23 36.6l4.2 2-25 153.4h-69.5V102.4z" style="fill:#191919"/></svg>

After

Width:  |  Height:  |  Size: 404 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g fill="#000000">
<path d="M7.754 2l.463.41c.343.304.687.607 1.026.915C11.44 5.32 13.3 7.565 14.7 10.149c.072.132.137.268.202.403l.098.203-.108.057-.081-.115-.21-.299-.147-.214c-1.019-1.479-2.04-2.96-3.442-4.145a6.563 6.563 0 00-1.393-.904c-1.014-.485-1.916-.291-2.69.505-.736.757-1.118 1.697-1.463 2.653-.045.123-.092.245-.139.367l-.082.215-.172-.055c.1-.348.192-.698.284-1.049.21-.795.42-1.59.712-2.356.31-.816.702-1.603 1.093-2.39.169-.341.338-.682.5-1.025h.092z"/>

After

Width:  |  Height:  |  Size: 1.3 KiB

+34 -12
View File
@@ -35,7 +35,8 @@ export default function Login() {
const [password, setPassword] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [requiresMfa, setRequiresMfa] = useState(false);
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([]);
const [samlProviders, setSamlProviders] = useState<OAuthProvider[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
const [loginMethod, setLoginMethod] = useState<string>('all');
@@ -117,7 +118,11 @@ export default function Login() {
// We'll use these full paths so the auth client knows where to redirect
const providerPaths = Object.keys(data.providerList || {});
setEnabledProviders(providerPaths);
const oauth = providerPaths.filter(path => path.includes('/oauth2/'));
const saml = providerPaths.filter(path => path.includes('/saml2/'));
setOauthProviders(oauth);
setSamlProviders(saml);
setLoginMethod(data.loginMethod || 'all');
} catch (err) {
console.error('[Login] Failed to fetch enabled providers:', err);
@@ -134,7 +139,7 @@ export default function Login() {
// In debug mode, check if any providers exist in the config
const hasProviders = DEBUG_SHOW_ALL_PROVIDERS
? Object.keys(oauthProviderConfig).length > 0
: enabledProviders.length > 0;
: (oauthProviders.length > 0 || samlProviders.length > 0);
setHasSSOProviders(hasProviders);
// Check if username/password authentication is allowed
@@ -147,7 +152,7 @@ export default function Login() {
// Hide email form if username/password auth is not allowed
setShowEmailForm(false);
}
}, [enabledProviders, loginMethod]);
}, [oauthProviders, samlProviders, loginMethod]);
// Handle query params (email prefill, success messages, and session expiry)
useEffect(() => {
@@ -340,15 +345,32 @@ export default function Login() {
<ErrorMessage error={error} />
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={enabledProviders}
/>
{/* OAuth section */}
{oauthProviders.length > 0 && (
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={oauthProviders}
/>
)}
{/* Divider between OAuth and Email - only show if SSO is available and username/password is allowed */}
{/* SAML section - show with divider if there are also OAuth providers */}
{samlProviders.length > 0 && (
<>
{oauthProviders.length > 0 && (
<DividerWithText text="SAML" respondsToDarkMode={false} opacity={0.4} />
)}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={samlProviders}
/>
</>
)}
{/* Divider between SSO and Email - only show if SSO is available and username/password is allowed */}
{hasSSOProviders && (loginMethod === 'all' || loginMethod === 'normal') && (
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
)}
@@ -17,11 +17,15 @@ export const oauthProviderConfig: Record<string, { label: string; file: string }
keycloak: { label: 'Keycloak', file: 'keycloak.svg' },
cloudron: { label: 'Cloudron', file: 'cloudron.svg' },
authentik: { label: 'Authentik', file: 'authentik.svg' },
oidc: { label: 'OIDC', file: 'oidc.svg' }
intune: { label: 'Intune', file: 'intune.svg' },
pocketId: { label: 'Pocket ID', file: 'pocketid.svg' },
oidc: { label: 'OIDC', file: 'oidc.svg' },
saml: { label: 'SAML', file: 'saml.svg' },
};
// Generic fallback for unknown providers
const GENERIC_PROVIDER_ICON = 'oidc.svg';
// Generic fallback icons for unknown providers
const GENERIC_OAUTH_ICON = 'oidc.svg';
const GENERIC_SAML_ICON = 'saml.svg';
interface OAuthButtonsProps {
onProviderClick: (provider: OAuthProvider) => void
@@ -42,6 +46,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
const providers = providersToShow.map(pathOrId => {
// Extract provider ID from full path (e.g., '/saml2/authenticate/stirling' -> 'stirling')
const providerId = pathOrId.split('/').pop() || pathOrId;
const isSamlProvider = pathOrId.includes('/saml2/');
if (providerId in oauthProviderConfig) {
// Known provider - use predefined icon and label
@@ -51,12 +56,12 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
...oauthProviderConfig[providerId]
};
}
// Unknown provider - use generic icon and capitalize ID for label
// Unknown provider - use appropriate generic icon based on auth type
return {
id: pathOrId, // Keep full path for redirect
providerId, // Store extracted ID for display lookup
label: providerId.charAt(0).toUpperCase() + providerId.slice(1),
file: GENERIC_PROVIDER_ICON
file: isSamlProvider ? GENERIC_SAML_ICON : GENERIC_OAUTH_ICON
};
});