Implemented SP-initiated SLO enabled with flag
# Conflicts: # frontend/src/proprietary/routes/Login.tsx
This commit is contained in:
+120
-45
@@ -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);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+24
-1
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user