Testing saml login/logout

# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java

# Conflicts:
#	frontend/src/proprietary/routes/AuthCallback.tsx

# Conflicts:
#	app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java
#	app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
This commit is contained in:
Dario Ghunney Ware
2026-01-24 12:41:10 +00:00
committed by DarioGii
parent c77cd73deb
commit bf51895b8e
14 changed files with 484 additions and 108 deletions
@@ -241,6 +241,9 @@ public class ApplicationProperties {
@JsonIgnore
public InputStream getIdpMetadataUri() throws IOException {
if (idpMetadataUri == null || idpMetadataUri.isBlank()) {
throw new IOException("security.saml2.idpMetadataUri is not configured");
}
if (idpMetadataUri.startsWith("classpath:")) {
return new ClassPathResource(idpMetadataUri.substring("classpath:".length()))
.getInputStream();
@@ -256,6 +259,11 @@ public class ApplicationProperties {
}
}
@JsonIgnore
public String getIdpMetadataUriLocation() {
return idpMetadataUri;
}
@JsonIgnore
public Resource getSpCert() {
if (spCert == null) return null;
@@ -29,7 +29,7 @@ public class RequestUriUtils {
|| normalizedUri.startsWith("/pdfium/")
|| normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
|| normalizedUri.startsWith("/login/")
|| normalizedUri.startsWith("/samples/")
|| normalizedUri.startsWith("/classic-logo/")
|| normalizedUri.startsWith("/modern-logo/")
@@ -4,9 +4,9 @@ logging.level.org.springframework.security=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.oauth2=DEBUG
#logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.security=DEBUG
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
logging.level.stirling.software.proprietary.security=DEBUG
logging.level.com.zaxxer.hikari=WARN
logging.level.stirling.software.SPDF.service.PdfJsonConversionService=INFO
logging.level.stirling.software.common.service.JobExecutorService=INFO
@@ -210,7 +210,7 @@ public class ProprietaryUIDataController {
SAML2 saml2 = securityProps.getSaml2();
// Only add SAML2 providers if loginMethod allows it
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String samlIdp = saml2.getIdpIssuer();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
// For SAML, we need to use the backend URL directly, not a relative path
@@ -6,6 +6,8 @@ import java.security.interfaces.RSAPrivateKey;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.springframework.core.io.Resource;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -56,11 +58,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
throws IOException {
if (!response.isCommitted()) {
Optional<String> samlNameId = resolveSamlNameId(authentication, request);
if (samlNameId.isPresent()) {
getRedirect_saml2(request, response, samlNameId.get());
return;
}
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
// Handle SAML2 logout redirection
getRedirect_saml2(request, response, samlAuthentication);
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
// Handle OAuth2 logout redirection
getRedirect_oauth2(request, response, oAuthToken);
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
@@ -90,19 +95,26 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
// Redirect for SAML2 authentication logout
private void getRedirect_saml2(
HttpServletRequest request,
HttpServletResponse response,
Saml2Authentication samlAuthentication)
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();
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
String nameIdValue = principal.name();
try {
// Read certificate from the resource
Resource certificateResource = samlConf.getSpCert();
@@ -130,13 +142,45 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
} catch (Exception e) {
log.error(
"Error retrieving logout URL from Provider {} for user {}",
samlConf.getProvider(),
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);
}
}
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);
}
}
}
} catch (Exception ex) {
log.debug("Unable to resolve SAML NameID from JWT during logout", ex);
}
}
return Optional.empty();
}
// Redirect for OAuth2 authentication logout
private void getRedirect_oauth2(
HttpServletRequest request,
@@ -24,6 +24,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;
@@ -203,10 +204,10 @@ public class SecurityConfiguration {
if (loginEnabledValue) {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
http.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, LogoutFilter.class)
.addFilterAfter(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
http.sessionManagement(
sessionManagement ->
@@ -61,6 +61,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
if (isPublicAuthEndpoint(requestURI, contextPath)) {
filterChain.doFilter(request, response);
return;
}
if (isStaticResource(contextPath, requestURI)) {
filterChain.doFilter(request, response);
return;
@@ -174,7 +179,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
log.debug("Setting authentication for user: {}", username);
SecurityContextHolder.getContext().setAuthentication(authToken);
log.debug(
"Authentication set successfully: {}",
SecurityContextHolder.getContext().getAuthentication());
} else {
throw new UsernameNotFoundException("User not found: " + username);
}
@@ -4,6 +4,7 @@ import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -32,6 +33,7 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -43,16 +45,19 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
private final UserService userService;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final boolean loginEnabledValue;
private final JwtServiceInterface jwtService;
public UserAuthenticationFilter(
@Lazy ApplicationProperties.Security securityProp,
@Lazy UserService userService,
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabledValue) {
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Lazy JwtServiceInterface jwtService) {
this.securityProp = securityProp;
this.userService = userService;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabledValue = loginEnabledValue;
this.jwtService = jwtService;
}
@Override
@@ -135,7 +140,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
// Check if the authenticated user is disabled and invalidate their session if so
if (authentication != null && authentication.isAuthenticated()) {
if (authentication.isAuthenticated()) {
UserLoginType loginMethod = UserLoginType.UNKNOWN;
@@ -146,7 +151,14 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String username = null;
if (principal instanceof UserDetails detailsUser) {
username = detailsUser.getUsername();
loginMethod = UserLoginType.USERDETAILS;
loginMethod = resolveLoginTypeFromJwt(request);
if (loginMethod == UserLoginType.SAML2USER) {
SAML2 saml2 = securityProp.getSaml2();
blockRegistration = saml2 != null && saml2.getBlockRegistration();
} else if (loginMethod == UserLoginType.OAUTH2USER) {
OAUTH2 oAuth = securityProp.getOauth2();
blockRegistration = oAuth != null && oAuth.getBlockRegistration();
}
} else if (principal instanceof OAuth2User oAuth2User) {
username = oAuth2User.getName();
loginMethod = UserLoginType.OAUTH2USER;
@@ -241,6 +253,30 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private UserLoginType resolveLoginTypeFromJwt(HttpServletRequest request) {
if (jwtService == null) {
return UserLoginType.USERDETAILS;
}
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) {
String type = authType.toString().toUpperCase();
if ("SAML2".equals(type)) {
return UserLoginType.SAML2USER;
} else if ("OAUTH2".equals(type)) {
return UserLoginType.OAUTH2USER;
}
}
}
} catch (Exception e) {
log.debug("Unable to resolve login type from JWT claims", e);
}
return UserLoginType.USERDETAILS;
}
private enum UserLoginType {
USERDETAILS("UserDetails"),
OAUTH2USER("OAuth2User"),
@@ -7,6 +7,8 @@ import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -33,6 +35,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;
@@ -70,8 +73,7 @@ public class CustomSaml2AuthenticationSuccessHandler
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE 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.isSamlEligible(user)) {
// User is not grandfathered and no ENTERPRISE license - block SAML login
@@ -172,7 +174,11 @@ public class CustomSaml2AuthenticationSuccessHandler
// Extract SSO provider information from SAML2 assertion
String ssoProviderId = saml2Principal.nameId();
String ssoProvider = "saml2"; // fixme
String ssoProvider =
(saml2Properties.getIdpIssuer() != null
&& !saml2Properties.getIdpIssuer().isBlank())
? saml2Properties.getIdpIssuer()
: saml2Properties.getRegistrationId();
log.debug(
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
@@ -188,12 +194,21 @@ public class CustomSaml2AuthenticationSuccessHandler
SAML2);
log.debug("Successfully processed authentication for user: {}", username);
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication,
Map.of("authType", AuthenticationType.SAML2));
Map<String, Object> claims = new HashMap<>();
claims.put("authType", AuthenticationType.SAML2);
claims.put("samlNameId", saml2Principal.nameId());
List<String> sessionIndexes = saml2Principal.sessionIndexes();
if (sessionIndexes != null && !sessionIndexes.isEmpty()) {
claims.put("samlSessionIndexes", sessionIndexes);
}
if (ssoProvider != null) {
claims.put("samlProvider", ssoProvider);
}
if (saml2Properties.getRegistrationId() != null) {
claims.put("samlRegistrationId", saml2Properties.getRegistrationId());
}
String jwt = jwtService.generateToken(authentication, claims);
// Build context-aware redirect URL based on the original request
String redirectUrl =
@@ -94,7 +94,7 @@ public class JwtSaml2AuthenticationRequestRepository
if (token != null) {
tokenStore.remove(authnRequestId);
log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
log.info("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
return token;
} else {
log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId);
@@ -1,9 +1,22 @@
package stirling.software.proprietary.security.saml2;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.Collections;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -19,6 +32,10 @@ import org.springframework.security.saml2.provider.service.registration.RelyingP
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import jakarta.servlet.http.HttpServletRequest;
@@ -35,37 +52,26 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
@RequiredArgsConstructor
public class Saml2Configuration {
private static final String SAML_METADATA_NS = "urn:oasis:names:tc:SAML:2.0:metadata";
private static final String XML_DSIG_NS = "http://www.w3.org/2000/09/xmldsig#";
private final ApplicationProperties applicationProperties;
@Bean
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
Optional<IdpMetadataInfo> metadataInfo = loadIdpMetadata(samlConf);
log.info(
"Initializing SAML2 configuration with registration ID: {}",
samlConf.getRegistrationId());
// Load IdP certificate
X509Certificate idpCert;
try {
Resource idpCertResource = samlConf.getIdpCert();
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
if (!idpCertResource.exists()) {
log.error(
"SAML2 IdP certificate not found at: {}", idpCertResource.getDescription());
throw new IllegalStateException(
"SAML2 IdP certificate file does not exist: "
+ idpCertResource.getDescription());
}
idpCert = CertificateUtils.readCertificate(idpCertResource);
log.info(
"Successfully loaded IdP certificate. Subject: {}",
idpCert.getSubjectX500Principal().getName());
} catch (Exception e) {
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
}
// Load IdP certificate either from metadata or fallback resource
X509Certificate idpCert =
metadataInfo
.map(IdpMetadataInfo::signingCertificate)
.orElseGet(() -> loadIdpCertificateFromResource(samlConf));
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
@@ -102,6 +108,10 @@ public class Saml2Configuration {
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
}
metadataInfo.ifPresent(info -> applyMetadataOverrides(samlConf, info));
String idpIssuer = samlConf.getIdpIssuer();
String idpSingleLoginUrl = samlConf.getIdpSingleLoginUrl();
String idpSingleLogoutUrl = samlConf.getIdpSingleLogoutUrl();
// Get backend URL from configuration (for SAML endpoints)
String backendUrl = applicationProperties.getSystem().getBackendUrl();
@@ -123,24 +133,22 @@ public class Saml2Configuration {
.signingX509Credentials(c -> c.add(signingCredential))
.entityId(entityId)
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceLocation(idpSingleLogoutUrl)
.singleLogoutServiceResponseLocation(sloResponseLocation)
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation(acsLocation)
.authnRequestsSigned(true)
.assertingPartyMetadata(
metadata ->
metadata.entityId(samlConf.getIdpIssuer())
metadata.entityId(idpIssuer)
.verificationX509Credentials(
c -> c.add(verificationCredential))
.singleSignOnServiceBinding(
Saml2MessageBinding.POST)
.singleSignOnServiceLocation(
samlConf.getIdpSingleLoginUrl())
.singleSignOnServiceLocation(idpSingleLoginUrl)
.singleLogoutServiceBinding(
Saml2MessageBinding.POST)
.singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceLocation(idpSingleLogoutUrl)
.singleLogoutServiceResponseLocation(
sloResponseLocation)
.wantAuthnRequestsSigned(true))
@@ -206,6 +214,153 @@ public class Saml2Configuration {
return resolver;
}
private X509Certificate loadIdpCertificateFromResource(SAML2 samlConf) {
try {
Resource idpCertResource = samlConf.getIdpCert();
if (idpCertResource == null) {
throw new IllegalStateException("SAML2 IdP certificate resource is not defined");
}
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
if (!idpCertResource.exists()) {
throw new IllegalStateException(
"SAML2 IdP certificate file does not exist: "
+ idpCertResource.getDescription());
}
X509Certificate certificate = CertificateUtils.readCertificate(idpCertResource);
log.info(
"Successfully loaded IdP certificate. Subject: {}",
certificate.getSubjectX500Principal().getName());
return certificate;
} catch (Exception e) {
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
}
}
private void applyMetadataOverrides(SAML2 samlConf, IdpMetadataInfo metadataInfo) {
log.info(
"Applying IdP metadata overrides for registration: {}",
samlConf.getRegistrationId());
overrideIfPresent(metadataInfo.entityId(), samlConf::setIdpIssuer);
overrideIfPresent(metadataInfo.singleSignOnServiceUrl(), samlConf::setIdpSingleLoginUrl);
overrideIfPresent(metadataInfo.singleLogoutServiceUrl(), samlConf::setIdpSingleLogoutUrl);
}
private Optional<IdpMetadataInfo> loadIdpMetadata(SAML2 samlConf) {
String metadataLocation = samlConf.getIdpMetadataUriLocation();
if (metadataLocation == null || metadataLocation.isBlank()) {
return Optional.empty();
}
try (InputStream metadataStream = samlConf.getIdpMetadataUri()) {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
try {
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
} catch (IllegalArgumentException ignored) {
log.debug("XML parser does not support external entity restrictions");
}
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(metadataStream);
Element entityDescriptor = doc.getDocumentElement();
if (entityDescriptor == null) {
log.warn("No EntityDescriptor found in SAML metadata: {}", metadataLocation);
return Optional.empty();
}
String entityId = entityDescriptor.getAttribute("entityID");
NodeList idpDescriptors =
entityDescriptor.getElementsByTagNameNS(SAML_METADATA_NS, "IDPSSODescriptor");
if (idpDescriptors.getLength() == 0) {
log.warn("No IDPSSODescriptor found in SAML metadata: {}", metadataLocation);
return Optional.empty();
}
Element idpDescriptor = (Element) idpDescriptors.item(0);
String ssoUrl = extractServiceLocation(idpDescriptor, "SingleSignOnService");
String sloUrl = extractServiceLocation(idpDescriptor, "SingleLogoutService");
X509Certificate signingCert = extractSigningCertificate(idpDescriptor);
log.info("Loaded IdP metadata from: {}", metadataLocation);
return Optional.of(new IdpMetadataInfo(entityId, ssoUrl, sloUrl, signingCert));
} catch (IOException
| ParserConfigurationException
| SAXException
| CertificateException e) {
log.warn("Failed to parse SAML metadata from {}: {}", metadataLocation, e.getMessage());
return Optional.empty();
}
}
private String extractServiceLocation(Element descriptor, String tagName) {
NodeList services = descriptor.getElementsByTagNameNS(SAML_METADATA_NS, tagName);
String fallback = null;
for (int i = 0; i < services.getLength(); i++) {
Element service = (Element) services.item(i);
String location = service.getAttribute("Location");
String binding = service.getAttribute("Binding");
if (!hasText(location)) {
continue;
}
if (Saml2MessageBinding.POST.getUrn().equals(binding)) {
return location;
}
if (fallback == null) {
fallback = location;
}
}
return fallback;
}
private X509Certificate extractSigningCertificate(Element descriptor)
throws CertificateException {
NodeList keyDescriptors =
descriptor.getElementsByTagNameNS(SAML_METADATA_NS, "KeyDescriptor");
for (int i = 0; i < keyDescriptors.getLength(); i++) {
Element keyDescriptor = (Element) keyDescriptors.item(i);
String use = keyDescriptor.getAttribute("use");
if (hasText(use) && !"signing".equalsIgnoreCase(use)) {
continue;
}
NodeList certificateNodes =
keyDescriptor.getElementsByTagNameNS(XML_DSIG_NS, "X509Certificate");
if (certificateNodes.getLength() == 0) {
continue;
}
String certificateValue = certificateNodes.item(0).getTextContent();
if (!hasText(certificateValue)) {
continue;
}
byte[] decoded =
Base64.getMimeDecoder().decode(certificateValue.replaceAll("\\s+", ""));
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
return (X509Certificate)
certificateFactory.generateCertificate(new ByteArrayInputStream(decoded));
}
return null;
}
private boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
private void overrideIfPresent(String value, Consumer<String> setter) {
if (hasText(value)) {
setter.accept(value.trim());
}
}
private record IdpMetadataInfo(
String entityId,
String singleSignOnServiceUrl,
String singleLogoutServiceUrl,
X509Certificate signingCertificate) {}
private static void logAuthnRequestDetails(AuthnRequest authnRequest) {
String message =
"""
+6 -1
View File
@@ -134,10 +134,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Listen for jwt-available event (triggered by desktop auth or other sources)
const handleJwtAvailable = () => {
console.debug('[Auth] JWT available event received, refreshing session');
console.log('[Auth] JWT available event received, refreshing session');
// Set loading true while we re-validate
setLoading(true);
void initializeAuth();
};
// Log that we're setting up the listener
console.debug('[Auth] Setting up jwt-available event listener');
window.addEventListener('jwt-available', handleJwtAvailable);
// Subscribe to auth state changes
+146 -47
View File
@@ -1,76 +1,175 @@
import { useEffect } from 'react';
import { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { springAuth } from '@app/auth/springAuthClient';
import { handleAuthCallbackSuccess } from '@app/extensions/authCallback';
import styles from '@app/routes/AuthCallback.module.css';
import { useAuth } from '@app/auth/UseSession';
// Use sessionStorage to track callback handling across React StrictMode double-renders
// Timestamp-based to allow retry after 10 seconds (e.g., if user tries again)
const AUTH_CALLBACK_TIMESTAMP_KEY = 'stirling_auth_callback_ts';
/**
* OAuth Callback Handler
* OAuth/SAML Callback Handler
*
* This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back.
* This component is rendered after OAuth/SAML providers redirect back.
* The JWT is passed in the URL fragment (#access_token=...) by the Spring backend.
* We extract it, store in localStorage, and redirect to the home page.
*
* Flow:
* 1. Extract JWT from URL hash
* 2. Store in localStorage
* 3. Fire jwt-available event (AuthProvider will validate)
* 4. Wait for AuthProvider to confirm session
* 5. Navigate to home only after session is confirmed
*/
export default function AuthCallback() {
const navigate = useNavigate();
const { session, loading: authLoading } = useAuth();
const [status, setStatus] = useState<'extracting' | 'validating' | 'error'>('extracting');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [tokenStored, setTokenStored] = useState(false);
const processingRef = useRef(false);
// Step 1: Extract and store the token
useEffect(() => {
const handleCallback = async () => {
try {
console.log('[AuthCallback] Handling OAuth callback...');
// Check if we've already handled this callback recently (within 10 seconds)
const handledTimestamp = sessionStorage.getItem(AUTH_CALLBACK_TIMESTAMP_KEY);
const now = Date.now();
if (handledTimestamp && (now - parseInt(handledTimestamp, 10)) < 10000) {
console.debug('[AuthCallback] Already handled recently, checking session state');
setTokenStored(true);
setStatus('validating');
return;
}
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1); // Remove '#'
const params = new URLSearchParams(hash);
const token = params.get('access_token');
// Use ref to prevent concurrent processing
if (processingRef.current) {
console.debug('[AuthCallback] Already processing, skipping');
return;
}
processingRef.current = true;
if (!token) {
console.error('[AuthCallback] No access_token in URL fragment');
console.log('[AuthCallback] Handling OAuth/SAML callback...');
console.debug('[AuthCallback] Current URL:', window.location.href);
console.debug('[AuthCallback] Hash:', window.location.hash);
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1); // Remove '#'
const params = new URLSearchParams(hash);
const token = params.get('access_token');
if (!token) {
console.error('[AuthCallback] No access_token in URL fragment');
console.debug('[AuthCallback] Full hash was:', hash);
setStatus('error');
setErrorMessage('Authentication failed - no token received from server.');
setTimeout(() => {
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed - no token received.' }
});
}, 2000);
return;
}
// Mark as handled BEFORE storing token
sessionStorage.setItem(AUTH_CALLBACK_TIMESTAMP_KEY, now.toString());
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token);
console.log('[AuthCallback] JWT stored in localStorage');
// Clear the hash from URL to prevent re-processing on page refresh
if (window.history.replaceState) {
window.history.replaceState(null, '', window.location.pathname);
}
// Update status
setStatus('validating');
// Dispatch jwt-available event - AuthProvider will validate the token
console.log('[AuthCallback] Firing jwt-available event for AuthProvider to validate');
window.dispatchEvent(new CustomEvent('jwt-available'));
// Small delay to allow AuthProvider to receive the event and start loading
// before we set tokenStored (which triggers the session watching effect)
setTimeout(() => {
console.log('[AuthCallback] Setting tokenStored=true to start watching session');
setTokenStored(true);
}, 50);
return () => {
processingRef.current = false;
};
}, [navigate]);
// Step 2: Wait for AuthProvider to validate and provide session
useEffect(() => {
if (!tokenStored) {
return; // Token not stored yet, wait
}
console.debug('[AuthCallback] Checking auth state:', {
authLoading,
hasSession: !!session,
tokenStored
});
// Still loading - wait for AuthProvider to finish validation
if (authLoading) {
return;
}
// AuthProvider finished loading
if (session) {
// Session validated successfully!
console.log('[AuthCallback] Session confirmed, user:', session.user?.email);
console.log('[AuthCallback] Redirecting to home...');
navigate('/', { replace: true });
} else {
// No session after AuthProvider finished - token was invalid
console.error('[AuthCallback] No session after validation - token may be invalid');
// Check if JWT is still in localStorage (AuthProvider removes it on 401)
const jwtStillExists = !!localStorage.getItem('stirling_jwt');
if (!jwtStillExists) {
setStatus('error');
setErrorMessage('Authentication failed - invalid or expired token.');
setTimeout(() => {
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed - no token received.' }
state: { error: 'Authentication failed - please try again.' }
});
return;
}
}, 2000);
}
// If JWT still exists but no session, AuthProvider might still be initializing
// Wait a bit more before giving up
}
}, [tokenStored, authLoading, session, navigate]);
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token);
console.log('[AuthCallback] JWT stored in localStorage');
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
// Validate the token and load user info
// This calls /api/v1/auth/me with the JWT to get user details
const { data, error } = await springAuth.getSession();
if (error || !data.session) {
console.error('[AuthCallback] Failed to validate token:', error);
localStorage.removeItem('stirling_jwt');
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed - invalid token.' }
});
return;
}
// Timeout: If we've been validating for too long, show error
useEffect(() => {
if (status !== 'validating' || !tokenStored) {
return;
}
await handleAuthCallbackSuccess(token);
console.log('[AuthCallback] Token validated, redirecting to home');
// Clear the hash from URL and redirect to home page
navigate('/', { replace: true });
} catch (error) {
console.error('[AuthCallback] Error:', error);
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed. Please try again.' }
});
setTimeout(() => {
navigate('/login', {
replace: true,
state: { error: 'Authentication timed out. Please try again.' }
});
}, 2000);
}
};
}, 10000); // 10 second timeout
handleCallback();
}, [navigate]);
return () => clearTimeout(timeoutId);
}, [status, tokenStored, session, navigate]);
return (
<div className={styles.page}>
@@ -31,10 +31,14 @@ export function setupApiInterceptors(client: AxiosInstance): void {
(config) => {
const jwtToken = getJwtTokenFromStorage();
const xsrfToken = getXsrfToken();
const url = config.url || 'unknown';
if (jwtToken && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${jwtToken}`;
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
console.debug('[API Client] Added JWT to request:', url);
} else if (!jwtToken) {
// Log when JWT is missing - helps debug auth issues
console.debug('[API Client] No JWT in localStorage for request:', url);
}
if (xsrfToken && !config.headers['X-XSRF-TOKEN']) {