mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
21
Commits
env
...
fix/saml-login
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaafa21842 | ||
|
|
1a62680dcc | ||
|
|
789642a824 | ||
|
|
e1199a1fd3 | ||
|
|
c0f9c9570e | ||
|
|
a8c4a0bfb1 | ||
|
|
99d728d815 | ||
|
|
3317babe5c | ||
|
|
5ad5d851b1 | ||
|
|
6cedc8c994 | ||
|
|
eba24b4d6c | ||
|
|
68e95c2652 | ||
|
|
de23752b44 | ||
|
|
b44bb7c50c | ||
|
|
537aee3ab6 | ||
|
|
a6ae74e794 | ||
|
|
50ec2d82d4 | ||
|
|
f95e2fdaa3 | ||
|
|
70c9cb36c3 | ||
|
|
1b737cbce5 | ||
|
|
4492fa39be |
+143
-36
@@ -215,75 +215,182 @@ public class ApplicationProperties {
|
||||
@Setter
|
||||
@ToString
|
||||
public static class SAML2 {
|
||||
private String provider;
|
||||
private Boolean enabled = false;
|
||||
private Boolean autoCreateUser = false;
|
||||
private Boolean blockRegistration = false;
|
||||
private String registrationId = "stirling";
|
||||
private Boolean enableSingleLogout = false;
|
||||
|
||||
@ToString.Exclude
|
||||
@JsonProperty("idpMetadataUri")
|
||||
@ToString.Exclude private String metadataUri;
|
||||
|
||||
private Provider provider = new Provider();
|
||||
private SP sp = new SP();
|
||||
|
||||
// Legacy field mappings for backward compatibility
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpMetadataUri;
|
||||
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpSingleLogoutUrl;
|
||||
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpSingleLoginUrl;
|
||||
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpIssuer;
|
||||
|
||||
@JsonProperty("idpCert")
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpEntityId;
|
||||
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpCert;
|
||||
|
||||
@ToString.Exclude
|
||||
@JsonProperty("privateKey")
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String privateKey;
|
||||
|
||||
@ToString.Exclude
|
||||
@JsonProperty("spCert")
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String spCert;
|
||||
|
||||
/** Migrate legacy flat properties to new nested structure on set. */
|
||||
public void setIdpMetadataUri(String value) {
|
||||
this.idpMetadataUri = value;
|
||||
if (value != null
|
||||
&& !value.isBlank()
|
||||
&& (metadataUri == null || metadataUri.isBlank())) {
|
||||
this.metadataUri = value;
|
||||
}
|
||||
}
|
||||
|
||||
public void setIdpSingleLoginUrl(String value) {
|
||||
this.idpSingleLoginUrl = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.provider.setSingleLoginUrl(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setIdpSingleLogoutUrl(String value) {
|
||||
this.idpSingleLogoutUrl = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.provider.setSingleLogoutUrl(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setIdpIssuer(String value) {
|
||||
this.idpIssuer = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.provider.setEntityId(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setIdpEntityId(String value) {
|
||||
this.idpEntityId = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.provider.setEntityId(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setIdpCert(String value) {
|
||||
this.idpCert = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.provider.setCert(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setPrivateKey(String value) {
|
||||
this.privateKey = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.sp.setPrivateKey(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setSpCert(String value) {
|
||||
this.spCert = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.sp.setCert(value);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public InputStream getIdpMetadataUri() throws IOException {
|
||||
if (idpMetadataUri.startsWith("classpath:")) {
|
||||
return new ClassPathResource(idpMetadataUri.substring("classpath:".length()))
|
||||
public InputStream getMetadataUriAsStream() throws IOException {
|
||||
String uri = getEffectiveMetadataUri();
|
||||
if (uri == null || uri.isBlank()) {
|
||||
throw new IOException("security.saml2.metadataUri is not configured");
|
||||
}
|
||||
if (uri.startsWith("classpath:")) {
|
||||
return new ClassPathResource(uri.substring("classpath:".length()))
|
||||
.getInputStream();
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(idpMetadataUri);
|
||||
URL url = uri.toURL();
|
||||
URI parsedUri = new URI(uri);
|
||||
URL url = parsedUri.toURL();
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
return connection.getInputStream();
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IOException("Invalid URI format: " + idpMetadataUri, e);
|
||||
throw new IOException("Invalid URI format: " + uri, e);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getSpCert() {
|
||||
if (spCert == null) return null;
|
||||
if (spCert.startsWith("classpath:")) {
|
||||
return new ClassPathResource(spCert.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(spCert);
|
||||
public String getEffectiveMetadataUri() {
|
||||
if (metadataUri != null && !metadataUri.isBlank()) {
|
||||
return metadataUri;
|
||||
}
|
||||
return idpMetadataUri; // Legacy fallback
|
||||
}
|
||||
|
||||
/** IdP configuration - manual fallback when metadata is unavailable. */
|
||||
@Data
|
||||
public static class Provider {
|
||||
private String name = ""; // Display name only
|
||||
private String singleLoginUrl;
|
||||
private String singleLogoutUrl;
|
||||
private String entityId;
|
||||
@ToString.Exclude private String cert;
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getCertResource() {
|
||||
if (cert == null) return null;
|
||||
if (cert.startsWith("classpath:")) {
|
||||
return new ClassPathResource(cert.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(cert);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getIdpCert() {
|
||||
if (idpCert == null) return null;
|
||||
if (idpCert.startsWith("classpath:")) {
|
||||
return new ClassPathResource(idpCert.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(idpCert);
|
||||
}
|
||||
}
|
||||
/** Service Provider (SP) credentials for signing SAML requests. */
|
||||
@Data
|
||||
public static class SP {
|
||||
@ToString.Exclude private String privateKey;
|
||||
@ToString.Exclude private String cert;
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getPrivateKey() {
|
||||
if (privateKey == null) return null;
|
||||
if (privateKey.startsWith("classpath:")) {
|
||||
return new ClassPathResource(privateKey.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(privateKey);
|
||||
@JsonIgnore
|
||||
public Resource getPrivateKeyResource() {
|
||||
if (privateKey == null) return null;
|
||||
if (privateKey.startsWith("classpath:")) {
|
||||
return new ClassPathResource(privateKey.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getCertResource() {
|
||||
if (cert == null) return null;
|
||||
if (cert.startsWith("classpath:")) {
|
||||
return new ClassPathResource(cert.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(cert);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/")
|
||||
@@ -165,9 +165,9 @@ 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 +
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers
|
||||
// enableLogin)
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||
|
||||
+8
-8
@@ -31,9 +31,9 @@ class ApplicationPropertiesSaml2HttpTest {
|
||||
String url = server.url("/meta").toString();
|
||||
|
||||
var s = new ApplicationProperties.Security.SAML2();
|
||||
s.setIdpMetadataUri(url);
|
||||
s.setMetadataUri(url);
|
||||
|
||||
try (InputStream in = s.getIdpMetadataUri()) {
|
||||
try (InputStream in = s.getMetadataUriAsStream()) {
|
||||
String body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertTrue(body.contains("EntityDescriptor"));
|
||||
}
|
||||
@@ -44,9 +44,9 @@ class ApplicationPropertiesSaml2HttpTest {
|
||||
void idpMetadataUri_invalidUri_triggers_catch_and_throwsIOException() {
|
||||
// Ungültige URI -> new URI(...) wirft URISyntaxException -> catch -> IOException
|
||||
var s = new ApplicationProperties.Security.SAML2();
|
||||
s.setIdpMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
|
||||
s.setMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
|
||||
|
||||
assertThrows(IOException.class, s::getIdpMetadataUri);
|
||||
assertThrows(IOException.class, s::getMetadataUriAsStream);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -57,8 +57,8 @@ class ApplicationPropertiesSaml2HttpTest {
|
||||
Path tmp = Files.createTempFile("spdf-spcert-", ".crt");
|
||||
Files.writeString(tmp, "CERT");
|
||||
|
||||
s.setSpCert(tmp.toString());
|
||||
Resource r = s.getSpCert();
|
||||
s.getSp().setCert(tmp.toString());
|
||||
Resource r = s.getSp().getCertResource();
|
||||
|
||||
assertNotNull(r);
|
||||
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
|
||||
@@ -71,8 +71,8 @@ class ApplicationPropertiesSaml2HttpTest {
|
||||
|
||||
// bewusst nicht existierender Pfad -> else-Zweig wird trotzdem genommen
|
||||
String missing = "/this/path/does/not/exist/idp.crt";
|
||||
s.setIdpCert(missing);
|
||||
Resource r = s.getIdpCert();
|
||||
s.getProvider().setCert(missing);
|
||||
Resource r = s.getProvider().getCertResource();
|
||||
|
||||
assertNotNull(r);
|
||||
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
|
||||
|
||||
+15
-16
@@ -15,9 +15,9 @@ class ApplicationPropertiesSaml2ResourceTest {
|
||||
@Test
|
||||
void idpMetadataUri_classpath_is_resolved() throws Exception {
|
||||
var s = new ApplicationProperties.Security.SAML2();
|
||||
s.setIdpMetadataUri("classpath:saml/dummy.txt");
|
||||
s.setMetadataUri("classpath:saml/dummy.txt");
|
||||
|
||||
try (InputStream in = s.getIdpMetadataUri()) {
|
||||
try (InputStream in = s.getMetadataUriAsStream()) {
|
||||
assertNotNull(in, "Classpath InputStream should not be null");
|
||||
String txt = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertTrue(txt.contains("ok"));
|
||||
@@ -28,27 +28,26 @@ class ApplicationPropertiesSaml2ResourceTest {
|
||||
void spCert_idpCert_privateKey_null_classpath_and_filesystem() throws Exception {
|
||||
var s = new ApplicationProperties.Security.SAML2();
|
||||
|
||||
s.setSpCert(null);
|
||||
s.setIdpCert(null);
|
||||
s.setPrivateKey(null);
|
||||
assertNull(s.getSpCert());
|
||||
assertNull(s.getIdpCert());
|
||||
assertNull(s.getPrivateKey());
|
||||
// Test null values via the nested structure
|
||||
assertNull(s.getSp().getCertResource());
|
||||
assertNull(s.getProvider().getCertResource());
|
||||
assertNull(s.getSp().getPrivateKeyResource());
|
||||
|
||||
s.setSpCert("classpath:saml/dummy.txt");
|
||||
s.setIdpCert("classpath:saml/dummy.txt");
|
||||
s.setPrivateKey("classpath:saml/dummy.txt");
|
||||
Resource sp = s.getSpCert();
|
||||
Resource idp = s.getIdpCert();
|
||||
Resource pk = s.getPrivateKey();
|
||||
// Set classpath resources via the nested structure
|
||||
s.getSp().setCert("classpath:saml/dummy.txt");
|
||||
s.getProvider().setCert("classpath:saml/dummy.txt");
|
||||
s.getSp().setPrivateKey("classpath:saml/dummy.txt");
|
||||
Resource sp = s.getSp().getCertResource();
|
||||
Resource idp = s.getProvider().getCertResource();
|
||||
Resource pk = s.getSp().getPrivateKeyResource();
|
||||
assertTrue(sp.exists());
|
||||
assertTrue(idp.exists());
|
||||
assertTrue(pk.exists());
|
||||
|
||||
Path tmp = Files.createTempFile("spdf-key-", ".pem");
|
||||
Files.writeString(tmp, "KEY");
|
||||
s.setPrivateKey(tmp.toString());
|
||||
Resource pkFs = s.getPrivateKey();
|
||||
s.getSp().setPrivateKey(tmp.toString());
|
||||
Resource pkFs = s.getSp().getPrivateKeyResource();
|
||||
assertNotNull(pkFs);
|
||||
assertTrue(pkFs.exists());
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
multipart.enabled=true
|
||||
logging.level.org.springframework=WARN
|
||||
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.oauth2=DEBUG
|
||||
#logging.level.org.opensaml=DEBUG
|
||||
#logging.level.stirling.software.proprietary.security=DEBUG
|
||||
logging.level.com.zaxxer.hikari=WARN
|
||||
|
||||
@@ -47,20 +47,23 @@ security:
|
||||
provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak'
|
||||
saml2:
|
||||
enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true)
|
||||
provider: "" # The name of your Provider
|
||||
enableSingleLogout: false # set to 'true' to enable Single Logout (SP-initiated SLO). Logs the user out from the IdP
|
||||
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
|
||||
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
|
||||
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
|
||||
metadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # RECOMMENDED: Your IdP's metadata URI. When provided, IdP config is auto-discovered.
|
||||
provider: # IdP manual configuration - use if metadataUri is not available
|
||||
name: "" # Display name for your IdP (optional)
|
||||
singleLoginUrl: "" # SSO URL
|
||||
singleLogoutUrl: "" # SLO URL
|
||||
entityId: "" # IdP Entity ID
|
||||
cert: classpath:okta.cert # IdP signing certificate
|
||||
sp: # Service Provider (your app) credentials for signing SAML requests. Generated from your keypair
|
||||
privateKey: classpath:saml-private-key.key # Your private key.
|
||||
cert: classpath:saml-public-cert.crt # Your signing certificate.
|
||||
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
|
||||
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
|
||||
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
|
||||
jwt:
|
||||
persistence: true # Set to 'true' to enable JWT key store
|
||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
|
||||
+4
-1
@@ -210,7 +210,10 @@ 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.getProvider().getEntityId() != null
|
||||
? saml2.getProvider().getEntityId()
|
||||
: saml2.getIdpIssuer(); // legacy fallback
|
||||
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
|
||||
|
||||
// For SAML, we need to use the backend URL directly, not a relative path
|
||||
|
||||
+153
-90
@@ -1,53 +1,58 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
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;
|
||||
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;
|
||||
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, JwtServiceInterface jwtService) {
|
||||
this(securityProperties, jwtService, null);
|
||||
}
|
||||
|
||||
public CustomLogoutSuccessHandler(
|
||||
ApplicationProperties.Security securityProperties,
|
||||
JwtServiceInterface jwtService,
|
||||
LogoutSuccessHandler samlLogoutHandler) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.jwtService = jwtService;
|
||||
this.samlLogoutHandler = samlLogoutHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
|
||||
@@ -56,13 +61,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
throws IOException {
|
||||
|
||||
if (!response.isCommitted()) {
|
||||
if (handleSamlLogout(request, response, authentication)) {
|
||||
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);
|
||||
getRedirectOauth2(request, response, oAuthToken);
|
||||
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
|
||||
// Handle Username/Password logout
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
@@ -74,71 +80,152 @@ 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 samlAuthentication)
|
||||
/**
|
||||
* 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 {
|
||||
// Logout locally if this is a SAMLResponse from to /logout instead of /logout/saml2/slo
|
||||
String samlResponse = request.getParameter("SAMLResponse");
|
||||
if (samlResponse != null && !samlResponse.isBlank()) {
|
||||
if (samlResponse.contains("/saml2/slo")) {
|
||||
log.info(
|
||||
"Received SAML LogoutResponse at /logout endpoint, completing logout locally");
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
SAML2 samlConf = securityProperties.getSaml2();
|
||||
String registrationId = samlConf.getRegistrationId();
|
||||
if (securityProperties.getSaml2().getEnableSingleLogout()) {
|
||||
log.info("SP-initiated SLO detected, logging out via IdP");
|
||||
|
||||
CustomSaml2AuthenticatedPrincipal principal =
|
||||
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
|
||||
if (authentication instanceof Saml2Authentication samlAuthentication) {
|
||||
if (samlLogoutHandler != null) {
|
||||
try {
|
||||
samlLogoutHandler.onLogoutSuccess(request, response, samlAuthentication);
|
||||
} catch (Exception e) {
|
||||
log.error("SP-initiated SLO failed, falling back to local logout", e);
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
} else {
|
||||
log.warn(
|
||||
"SAML SLO enabled but handler not configured, performing local logout only");
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
|
||||
String nameIdValue = principal.name();
|
||||
return true;
|
||||
} else {
|
||||
// Reconstruct Saml2Authentication from JWT claims for SLO
|
||||
Optional<Saml2Authentication> reconstructedAuth =
|
||||
reconstructSaml2AuthenticationFromJwt(request);
|
||||
|
||||
if (reconstructedAuth.isPresent()) {
|
||||
Saml2Authentication samlAuth = reconstructedAuth.get();
|
||||
|
||||
if (samlLogoutHandler != null) {
|
||||
try {
|
||||
samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
|
||||
} catch (Exception e) {
|
||||
log.error("SP-initiated SLO failed, falling back to local logout", e);
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
} else {
|
||||
log.warn(
|
||||
"SAML SLO enabled but handler not configured, performing local logout only");
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Read certificate from the resource
|
||||
Resource certificateResource = samlConf.getSpCert();
|
||||
X509Certificate certificate = CertificateUtils.readCertificate(certificateResource);
|
||||
String token = jwtService.extractToken(request);
|
||||
if (token == null || token.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
List<X509Certificate> certificates = new ArrayList<>();
|
||||
certificates.add(certificate);
|
||||
Map<String, Object> claims = jwtService.extractClaims(token);
|
||||
Object authType = claims.get("authType");
|
||||
|
||||
// Construct URLs required for SAML configuration
|
||||
SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates);
|
||||
if (authType == null || !"SAML2".equalsIgnoreCase(authType.toString())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Read private key for service provider
|
||||
Resource privateKeyResource = samlConf.getPrivateKey();
|
||||
RSAPrivateKey privateKey = CertificateUtils.readPrivateKey(privateKeyResource);
|
||||
// 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");
|
||||
|
||||
// Set service provider keys for the SamlClient
|
||||
samlClient.setSPKeys(certificate, privateKey);
|
||||
if (nameId == null || registrationId == null) {
|
||||
log.debug(
|
||||
"Missing required SAML claims for SLO reconstruction: nameId={}, registrationId={}",
|
||||
nameId,
|
||||
registrationId);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Build relay state to return user to login page after IdP logout
|
||||
String relayState =
|
||||
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
|
||||
List<String> sessionIndexes = Collections.emptyList();
|
||||
if (sessionIndexesObj instanceof List<?>) {
|
||||
sessionIndexes =
|
||||
((List<?>) sessionIndexesObj).stream().map(Object::toString).toList();
|
||||
}
|
||||
|
||||
// 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.getProvider(),
|
||||
nameIdValue,
|
||||
e);
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
// 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.error("Unable to reconstruct Saml2Authentication from JWT", ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect for OAuth2 authentication logout
|
||||
private void getRedirect_oauth2(
|
||||
private void getRedirectOauth2(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
OAuth2AuthenticationToken oAuthToken)
|
||||
@@ -169,7 +256,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);
|
||||
@@ -196,30 +283,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.
|
||||
|
||||
+45
-17
@@ -8,11 +8,13 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
|
||||
@@ -22,8 +24,12 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio
|
||||
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
|
||||
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;
|
||||
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;
|
||||
@@ -33,7 +39,6 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
|
||||
@@ -56,7 +61,6 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@@ -72,12 +76,10 @@ public class SecurityConfiguration {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final AppConfig appConfig;
|
||||
private final UserAuthenticationFilter userAuthenticationFilter;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final SessionPersistentRegistry sessionRegistry;
|
||||
private final PersistentLoginRepository persistentLoginRepository;
|
||||
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
||||
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
|
||||
@@ -92,14 +94,12 @@ public class SecurityConfiguration {
|
||||
@Lazy UserService userService,
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
||||
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
|
||||
AppConfig appConfig,
|
||||
ApplicationProperties applicationProperties,
|
||||
ApplicationProperties.Security securityProperties,
|
||||
UserAuthenticationFilter userAuthenticationFilter,
|
||||
JwtServiceInterface jwtService,
|
||||
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
|
||||
LoginAttemptService loginAttemptService,
|
||||
SessionPersistentRegistry sessionRegistry,
|
||||
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
|
||||
@Autowired(required = false)
|
||||
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
|
||||
@@ -112,14 +112,12 @@ public class SecurityConfiguration {
|
||||
this.userService = userService;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
this.runningProOrHigher = runningProOrHigher;
|
||||
this.appConfig = appConfig;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.securityProperties = securityProperties;
|
||||
this.userAuthenticationFilter = userAuthenticationFilter;
|
||||
this.jwtService = jwtService;
|
||||
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
this.persistentLoginRepository = persistentLoginRepository;
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
|
||||
@@ -196,7 +194,7 @@ public class SecurityConfiguration {
|
||||
http.cors(cors -> cors.configurationSource(corsSource));
|
||||
} else {
|
||||
// Explicitly disable CORS when no origins are configured
|
||||
http.cors(cors -> cors.disable());
|
||||
http.cors(AbstractHttpConfigurer::disable);
|
||||
}
|
||||
|
||||
http.csrf(CsrfConfigurer::disable);
|
||||
@@ -227,10 +225,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 ->
|
||||
@@ -250,17 +248,38 @@ 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 ->
|
||||
// Require POST to prevent logout CSRF attacks
|
||||
logout.logoutRequestMatcher(
|
||||
PathPatternRequestMatcher.withDefaults()
|
||||
.matcher("/logout"))
|
||||
.matcher(HttpMethod.POST, "/logout"))
|
||||
.logoutSuccessHandler(
|
||||
new CustomLogoutSuccessHandler(
|
||||
securityProperties, appConfig, jwtService))
|
||||
securityProperties,
|
||||
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
|
||||
@@ -384,9 +403,18 @@ 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.debug("SAML2 Single Logout (SLO) is enabled");
|
||||
http.saml2Logout(logout -> logout.logoutUrl("/logout/saml2/slo"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.debug("Login is not enabled.");
|
||||
log.info("Login is not enabled.");
|
||||
http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
|
||||
}
|
||||
return http.build();
|
||||
|
||||
-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
|
||||
*
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Filter that redirects browser requests to /login to the frontend URL when configured. This is
|
||||
* needed for development mode where frontend runs on a different port, and for SAML logout which
|
||||
* redirects to /login?logout after SLO completes.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@RequiredArgsConstructor
|
||||
public class LoginRedirectFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String path = request.getRequestURI();
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
|
||||
// Only process /login requests when frontendUrl is configured
|
||||
if (path.equals("/login") && frontendUrl != null && !frontendUrl.isBlank()) {
|
||||
// Check if this is a browser request (Accept: text/html) vs API request
|
||||
// (Accept: application/json)
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
boolean isBrowserRequest =
|
||||
acceptHeader != null
|
||||
&& acceptHeader.contains("text/html")
|
||||
&& !acceptHeader.contains("application/json");
|
||||
|
||||
if (isBrowserRequest) {
|
||||
// Preserve query parameters (e.g., ?logout=true, ?error=xxx)
|
||||
String queryString = request.getQueryString();
|
||||
String redirectUrl =
|
||||
frontendUrl + "/login" + (queryString != null ? "?" + queryString : "");
|
||||
log.debug("Redirecting browser request to frontend: {}", redirectUrl);
|
||||
response.sendRedirect(redirectUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
+39
-3
@@ -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"),
|
||||
|
||||
-5
@@ -53,9 +53,6 @@ public class OAuth2Configuration {
|
||||
ApplicationProperties applicationProperties, @Lazy UserService userService) {
|
||||
this.userService = userService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
log.info(
|
||||
"OAuth2Configuration initialized - OAuth2 enabled: {}",
|
||||
applicationProperties.getSecurity().getOauth2().getEnabled());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -222,8 +219,6 @@ public class OAuth2Configuration {
|
||||
name,
|
||||
oauth.getIssuer(),
|
||||
REDIRECT_URI_PATH + name);
|
||||
} else {
|
||||
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
|
||||
}
|
||||
|
||||
return isValid
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-8
@@ -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 =
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+284
-36
@@ -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;
|
||||
|
||||
@@ -27,6 +44,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
|
||||
@Configuration
|
||||
@@ -35,45 +53,34 @@ 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 {
|
||||
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
|
||||
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);
|
||||
|
||||
// Load SP private key and certificate
|
||||
Resource privateKeyResource = samlConf.getPrivateKey();
|
||||
Resource certificateResource = samlConf.getSpCert();
|
||||
Resource privateKeyResource = samlConf.getSp().getPrivateKeyResource();
|
||||
Resource certificateResource = samlConf.getSp().getCertResource();
|
||||
|
||||
log.info("Loading SP private key from: {}", privateKeyResource.getDescription());
|
||||
log.debug("Loading SP private key from: {}", privateKeyResource.getDescription());
|
||||
if (!privateKeyResource.exists()) {
|
||||
log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription());
|
||||
throw new IllegalStateException(
|
||||
@@ -81,7 +88,7 @@ public class Saml2Configuration {
|
||||
+ privateKeyResource.getDescription());
|
||||
}
|
||||
|
||||
log.info("Loading SP certificate from: {}", certificateResource.getDescription());
|
||||
log.debug("Loading SP certificate from: {}", certificateResource.getDescription());
|
||||
if (!certificateResource.exists()) {
|
||||
log.error(
|
||||
"SAML2 SP certificate not found at: {}", certificateResource.getDescription());
|
||||
@@ -102,6 +109,43 @@ public class Saml2Configuration {
|
||||
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
|
||||
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
|
||||
}
|
||||
// Apply metadata overrides - metadata takes precedence over manual config
|
||||
metadataInfo.ifPresent(info -> applyMetadataOverrides(samlConf, info));
|
||||
|
||||
// Get IdP configuration - prefer values from metadata, fall back to manual config
|
||||
String idpEntityId =
|
||||
metadataInfo
|
||||
.map(IdpMetadataInfo::entityId)
|
||||
.filter(id -> id != null && !id.isBlank())
|
||||
.orElseGet(() -> samlConf.getProvider().getEntityId());
|
||||
|
||||
String idpSingleLoginUrl =
|
||||
metadataInfo
|
||||
.map(IdpMetadataInfo::singleSignOnServiceUrl)
|
||||
.filter(url -> url != null && !url.isBlank())
|
||||
.orElseGet(() -> samlConf.getProvider().getSingleLoginUrl());
|
||||
|
||||
String idpSingleLogoutUrl =
|
||||
metadataInfo
|
||||
.map(IdpMetadataInfo::singleLogoutServiceUrl)
|
||||
.filter(url -> url != null && !url.isBlank())
|
||||
.orElseGet(() -> samlConf.getProvider().getSingleLogoutUrl());
|
||||
|
||||
// Validate required IdP configuration
|
||||
if (idpEntityId == null || idpEntityId.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"SAML2 IdP Entity ID is required. Set security.saml2.entityId or provide security.saml2.metadataUri");
|
||||
}
|
||||
if (idpSingleLoginUrl == null || idpSingleLoginUrl.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"SAML2 IdP Single Sign-On URL is required. Set security.saml2.provider.singleLoginUrl or provide security.saml2.metadataUri");
|
||||
}
|
||||
|
||||
log.info(
|
||||
"SAML2 IdP configuration: entityId={}, ssoUrl={}, sloUrl={}",
|
||||
idpEntityId,
|
||||
idpSingleLoginUrl,
|
||||
idpSingleLogoutUrl);
|
||||
|
||||
// Get backend URL from configuration (for SAML endpoints)
|
||||
String backendUrl = applicationProperties.getSystem().getBackendUrl();
|
||||
@@ -116,40 +160,38 @@ public class Saml2Configuration {
|
||||
String entityId =
|
||||
backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId();
|
||||
String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}";
|
||||
String sloResponseLocation = backendUrl + "/login";
|
||||
// SP's Single Logout Service endpoint (where SP receives logout requests/responses from
|
||||
// IdP)
|
||||
String spSloLocation = backendUrl + "/logout/saml2/slo";
|
||||
|
||||
RelyingPartyRegistration rp =
|
||||
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
|
||||
.signingX509Credentials(c -> c.add(signingCredential))
|
||||
.entityId(entityId)
|
||||
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
|
||||
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
|
||||
.singleLogoutServiceResponseLocation(sloResponseLocation)
|
||||
.singleLogoutServiceLocation(spSloLocation)
|
||||
.singleLogoutServiceResponseLocation(spSloLocation)
|
||||
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
|
||||
.assertionConsumerServiceLocation(acsLocation)
|
||||
.authnRequestsSigned(true)
|
||||
.assertingPartyMetadata(
|
||||
metadata ->
|
||||
metadata.entityId(samlConf.getIdpIssuer())
|
||||
metadata.entityId(idpEntityId)
|
||||
.verificationX509Credentials(
|
||||
c -> c.add(verificationCredential))
|
||||
.singleSignOnServiceBinding(
|
||||
Saml2MessageBinding.POST)
|
||||
.singleSignOnServiceLocation(
|
||||
samlConf.getIdpSingleLoginUrl())
|
||||
.singleSignOnServiceLocation(idpSingleLoginUrl)
|
||||
.singleLogoutServiceBinding(
|
||||
Saml2MessageBinding.POST)
|
||||
.singleLogoutServiceLocation(
|
||||
samlConf.getIdpSingleLogoutUrl())
|
||||
.singleLogoutServiceResponseLocation(
|
||||
sloResponseLocation)
|
||||
.singleLogoutServiceLocation(idpSingleLogoutUrl)
|
||||
.wantAuthnRequestsSigned(true))
|
||||
.build();
|
||||
|
||||
log.info(
|
||||
"SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
|
||||
samlConf.getRegistrationId(),
|
||||
samlConf.getIdpIssuer());
|
||||
idpEntityId);
|
||||
return new InMemoryRelyingPartyRegistrationRepository(rp);
|
||||
}
|
||||
|
||||
@@ -206,6 +248,212 @@ public class Saml2Configuration {
|
||||
return resolver;
|
||||
}
|
||||
|
||||
private X509Certificate loadIdpCertificateFromResource(SAML2 samlConf) {
|
||||
try {
|
||||
Resource idpCertResource = samlConf.getProvider().getCertResource();
|
||||
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());
|
||||
SAML2.Provider provider = samlConf.getProvider();
|
||||
overrideIfPresent(metadataInfo.entityId(), provider::setEntityId);
|
||||
overrideIfPresent(metadataInfo.singleSignOnServiceUrl(), provider::setSingleLoginUrl);
|
||||
overrideIfPresent(metadataInfo.singleLogoutServiceUrl(), provider::setSingleLogoutUrl);
|
||||
|
||||
// Persist discovered metadata values to settings.yml
|
||||
persistMetadataToSettings(metadataInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists IdP metadata discovered values to settings.yml. This ensures the discovered
|
||||
* configuration is saved for future reference and survives restarts even if the metadata
|
||||
* endpoint becomes unavailable.
|
||||
*/
|
||||
private void persistMetadataToSettings(IdpMetadataInfo metadataInfo) {
|
||||
log.info(
|
||||
"Migrating discovered IdP metadata to SAML configuration. Existing configuration will be overridden.");
|
||||
|
||||
try {
|
||||
boolean anyPersisted = false;
|
||||
|
||||
if (hasText(metadataInfo.entityId())) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.saml2.provider.entityId", metadataInfo.entityId());
|
||||
log.info(" -> Persisted provider.entityId: {}", metadataInfo.entityId());
|
||||
anyPersisted = true;
|
||||
}
|
||||
|
||||
if (hasText(metadataInfo.singleSignOnServiceUrl())) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.saml2.provider.singleLoginUrl",
|
||||
metadataInfo.singleSignOnServiceUrl());
|
||||
log.info(
|
||||
" -> Persisted provider.singleLoginUrl: {}",
|
||||
metadataInfo.singleSignOnServiceUrl());
|
||||
anyPersisted = true;
|
||||
}
|
||||
|
||||
if (hasText(metadataInfo.singleLogoutServiceUrl())) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.saml2.provider.singleLogoutUrl",
|
||||
metadataInfo.singleLogoutServiceUrl());
|
||||
log.info(
|
||||
" -> Persisted provider.singleLogoutUrl: {}",
|
||||
metadataInfo.singleLogoutServiceUrl());
|
||||
anyPersisted = true;
|
||||
}
|
||||
|
||||
if (anyPersisted) {
|
||||
log.info(
|
||||
"IdP metadata successfully persisted to settings.yml. These values will be used as fallback if metadataUri becomes unavailable.");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to persist IdP metadata to settings.yml: {}. SAML will still work but discovered values won't be saved.",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<IdpMetadataInfo> loadIdpMetadata(SAML2 samlConf) {
|
||||
String metadataLocation = samlConf.getEffectiveMetadataUri();
|
||||
if (metadataLocation == null || metadataLocation.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try (InputStream metadataStream = samlConf.getMetadataUriAsStream()) {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
|
||||
// XXE prevention - disable all external entities and DTD processing
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setFeature(
|
||||
"http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||
|
||||
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 =
|
||||
"""
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
+37
-7
@@ -6,9 +6,9 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
@@ -26,17 +26,24 @@ class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
|
||||
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
|
||||
@Mock private ApplicationProperties.Security.SAML2 saml2;
|
||||
|
||||
private CustomLogoutSuccessHandler customLogoutSuccessHandler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
customLogoutSuccessHandler = new CustomLogoutSuccessHandler(securityProperties, jwtService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessfulLogout() throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
String token = "token";
|
||||
String logoutPath = "/login?logout=true";
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
@@ -50,10 +57,10 @@ class CustomLogoutSuccessHandlerTest {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
String logoutPath = "/login?logout=true";
|
||||
String token = "token";
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
@@ -71,6 +78,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
@@ -96,6 +106,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
@@ -128,6 +141,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
@@ -152,6 +168,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn("!!!" + error + "!!!");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
@@ -177,10 +196,12 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getContextPath()).thenReturn(url);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
@@ -204,6 +225,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
@@ -236,6 +260,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
@@ -269,6 +296,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(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 |
@@ -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"/>
|
||||
␍<path d="M8.448 11.822c-1.626.77-5.56 1.564-7.426 1.36C.717 11.576 3.71 4.05 5.18 2.91l-.095.218a4.638 4.638 0 01-.138.303l-.066.129c-.76 1.462-1.519 2.926-1.908 4.53a7.482 7.482 0 00-.228 1.689c-.01 1.34.824 2.252 2.217 2.309.67.027 1.347-.043 2.023-.114.294-.03.587-.061.88-.084.108-.008.214-.021.352-.039l.231-.028z"/>
|
||||
␍<path d="M3.825 14.781c-.445.034-.89.068-1.333.108 4.097.39 8.03-.277 11.91-1.644-1.265-2.23-2.97-3.991-4.952-5.522.026.098.084.169.141.239l.048.06c.17.226.348.448.527.67.409.509.818 1.018 1.126 1.578.778 1.42.356 2.648-1.168 3.296-1.002.427-2.097.718-3.18.892-1.03.164-2.075.243-3.119.323z"/>
|
||||
␍</g>
|
||||
␍</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -486,12 +486,6 @@ export const SAML2_PROVIDER: Provider = {
|
||||
description: 'Enable SAML2 authentication (Enterprise only)',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
type: 'text',
|
||||
label: 'Provider Name',
|
||||
description: 'The name of your SAML2 provider',
|
||||
},
|
||||
{
|
||||
key: 'registrationId',
|
||||
type: 'text',
|
||||
@@ -500,51 +494,66 @@ export const SAML2_PROVIDER: Provider = {
|
||||
defaultValue: 'stirling',
|
||||
},
|
||||
{
|
||||
key: 'idpMetadataUri',
|
||||
key: 'metadataUri',
|
||||
type: 'text',
|
||||
label: 'IDP Metadata URI',
|
||||
description: 'The URI for your provider\'s metadata',
|
||||
label: 'Metadata URI',
|
||||
description: 'Your IdP\'s metadata URI (recommended - auto-discovers IdP config)',
|
||||
placeholder: 'https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata',
|
||||
},
|
||||
{
|
||||
key: 'idpSingleLoginUrl',
|
||||
key: 'enableSingleLogout',
|
||||
type: 'switch',
|
||||
label: 'Enable Single Logout',
|
||||
description: 'Enable SP-initiated Single Logout (SLO)',
|
||||
defaultValue: false,
|
||||
},
|
||||
// IdP Provider settings (manual config - only needed if metadataUri not available)
|
||||
{
|
||||
key: 'provider.name',
|
||||
type: 'text',
|
||||
label: 'IDP Single Login URL',
|
||||
description: 'The URL for initiating SSO',
|
||||
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml',
|
||||
label: 'Provider Name',
|
||||
description: 'Display name for your IdP (optional, for reference only)',
|
||||
},
|
||||
{
|
||||
key: 'idpSingleLogoutUrl',
|
||||
key: 'provider.singleLoginUrl',
|
||||
type: 'text',
|
||||
label: 'IDP Single Logout URL',
|
||||
description: 'The URL for initiating SLO',
|
||||
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml',
|
||||
label: 'SSO URL',
|
||||
description: 'IdP Single Sign-On URL (auto-discovered from metadata if set)',
|
||||
placeholder: 'https://dev-XXXXXXXX.okta.com/app/.../sso/saml',
|
||||
},
|
||||
{
|
||||
key: 'idpIssuer',
|
||||
key: 'provider.singleLogoutUrl',
|
||||
type: 'text',
|
||||
label: 'IDP Issuer',
|
||||
description: 'The ID of your provider',
|
||||
label: 'SLO URL',
|
||||
description: 'IdP Single Logout URL (auto-discovered from metadata if set)',
|
||||
placeholder: 'https://dev-XXXXXXXX.okta.com/app/.../slo/saml',
|
||||
},
|
||||
{
|
||||
key: 'idpCert',
|
||||
key: 'provider.entityId',
|
||||
type: 'text',
|
||||
label: 'IDP Certificate',
|
||||
description: 'The certificate path (e.g., classpath:okta.cert)',
|
||||
label: 'IdP Entity ID',
|
||||
description: 'IdP Entity ID (auto-discovered from metadata if set)',
|
||||
},
|
||||
{
|
||||
key: 'provider.cert',
|
||||
type: 'text',
|
||||
label: 'IdP Certificate',
|
||||
description: 'IdP signing certificate path (auto-discovered from metadata if set)',
|
||||
placeholder: 'classpath:okta.cert',
|
||||
},
|
||||
// SP credentials
|
||||
{
|
||||
key: 'privateKey',
|
||||
key: 'sp.privateKey',
|
||||
type: 'text',
|
||||
label: 'Private Key',
|
||||
description: 'Your private key path',
|
||||
label: 'SP Private Key',
|
||||
description: 'Your Service Provider private key path',
|
||||
placeholder: 'classpath:saml-private-key.key',
|
||||
},
|
||||
{
|
||||
key: 'spCert',
|
||||
key: 'sp.cert',
|
||||
type: 'text',
|
||||
label: 'SP Certificate',
|
||||
description: 'Your signing certificate path',
|
||||
description: 'Your Service Provider signing certificate path',
|
||||
placeholder: 'classpath:saml-public-cert.crt',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -104,8 +104,8 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Setup default apiClient mock
|
||||
mockedApiClient.post = vi.fn();
|
||||
// Reset the post mock - use type assertion for compatibility with both axios and TauriHttpClient
|
||||
(mockedApiClient.post as ReturnType<typeof vi.fn>).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -251,40 +251,63 @@ describe('SpringAuthClient', () => {
|
||||
});
|
||||
|
||||
describe('signOut', () => {
|
||||
let mockForm: { method: string; action: string; style: { display: string }; submit: ReturnType<typeof vi.fn> };
|
||||
let appendChildSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock form creation and submission
|
||||
mockForm = {
|
||||
method: '',
|
||||
action: '',
|
||||
style: { display: '' },
|
||||
submit: vi.fn(),
|
||||
};
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockForm as unknown as HTMLElement);
|
||||
appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should successfully sign out and clear JWT', async () => {
|
||||
const mockToken = 'jwt-to-clear';
|
||||
localStorage.setItem('stirling_jwt', mockToken);
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {},
|
||||
} as any);
|
||||
|
||||
const result = await springAuth.signOut();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/auth/logout',
|
||||
null,
|
||||
expect.objectContaining({ withCredentials: true })
|
||||
);
|
||||
// JWT should be cleared from localStorage
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
// Should submit POST form to /logout for Spring Security logout handler
|
||||
expect(mockForm.method).toBe('POST');
|
||||
expect(mockForm.action).toBe('/logout');
|
||||
expect(mockForm.submit).toHaveBeenCalled();
|
||||
// Should return no error on successful signOut
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear JWT even if logout request fails', async () => {
|
||||
it('should create hidden form and submit POST to /logout', async () => {
|
||||
const mockToken = 'jwt-to-clear';
|
||||
localStorage.setItem('stirling_jwt', mockToken);
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500 },
|
||||
message: 'Server error',
|
||||
});
|
||||
await springAuth.signOut();
|
||||
|
||||
// Verify form was created with correct attributes
|
||||
expect(document.createElement).toHaveBeenCalledWith('form');
|
||||
expect(mockForm.style.display).toBe('none');
|
||||
expect(appendChildSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle signOut when no JWT is present', async () => {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
|
||||
const result = await springAuth.signOut();
|
||||
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
expect(result.error).toBeTruthy();
|
||||
// Should still submit POST form to /logout
|
||||
expect(mockForm.method).toBe('POST');
|
||||
expect(mockForm.action).toBe('/logout');
|
||||
expect(mockForm.submit).toHaveBeenCalled();
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -309,18 +309,25 @@ 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
|
||||
// Secure: only sent over HTTPS; Path=/logout: scoped to logout endpoint only
|
||||
document.cookie = `stirling_logout_token=${encodeURIComponent(token)}; path=/logout; max-age=30; SameSite=Lax; Secure`;
|
||||
}
|
||||
|
||||
// Clean up local storage
|
||||
@@ -355,13 +362,24 @@ class SpringAuthClient {
|
||||
console.warn('[SpringAuth] Failed to run platform auth cleanup', cleanupError);
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
// Notify listeners before redirect
|
||||
this.notifyListeners('SIGNED_OUT', null);
|
||||
|
||||
// Submit a POST form to /logout to trigger Spring Security's logout handler
|
||||
// Using POST prevents logout CSRF attacks (GET-based logout can be triggered by any site)
|
||||
console.log('[SpringAuth] Submitting POST to /logout for session invalidation');
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `${BASE_PATH}/logout`;
|
||||
form.style.display = 'none';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
|
||||
// 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 (
|
||||
|
||||
@@ -7,14 +7,12 @@ import { useAuth } from '@app/auth/UseSession';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useAccountLogout } from '@app/extensions/accountLogout';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse';
|
||||
|
||||
const AccountSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, signOut } = useAuth();
|
||||
const accountLogout = useAccountLogout();
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [usernameModalOpen, setUsernameModalOpen] = useState(false);
|
||||
|
||||
@@ -53,13 +51,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 () => {
|
||||
await accountLogout({ signOut, redirectToLogin });
|
||||
}, [accountLogout, redirectToLogin, signOut]);
|
||||
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]);
|
||||
|
||||
const handlePasswordSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
+14
-2
@@ -209,9 +209,20 @@ export default function AdminConnectionsSection() {
|
||||
return !!(providerSettings?.clientId);
|
||||
};
|
||||
|
||||
// Helper to get nested value from object using dot notation
|
||||
const getNestedValue = (obj: Record<string, any>, path: string): any => {
|
||||
return path.split('.').reduce((acc, part) => acc?.[part], obj);
|
||||
};
|
||||
|
||||
const getProviderSettings = (provider: Provider): Record<string, any> => {
|
||||
if (provider.id === 'saml2') {
|
||||
return settings?.saml2 || {};
|
||||
const saml2 = settings?.saml2 || {};
|
||||
// Flatten nested structure to match field keys with dot notation
|
||||
const result: Record<string, any> = {};
|
||||
provider.fields.forEach((field) => {
|
||||
result[field.key] = getNestedValue(saml2, field.key);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
if (provider.id === 'smtp') {
|
||||
@@ -297,8 +308,9 @@ export default function AdminConnectionsSection() {
|
||||
const deltaSettings: Record<string, any> = {};
|
||||
|
||||
if (provider.id === 'saml2') {
|
||||
// SAML2 settings
|
||||
// SAML2 settings - keys may use dot notation (e.g., 'provider.name')
|
||||
Object.keys(providerSettings).forEach((key) => {
|
||||
// Key already has dot notation for nested paths, just prepend security.saml2.
|
||||
deltaSettings[`security.saml2.${key}`] = providerSettings[key];
|
||||
});
|
||||
} else if (provider.id === 'oauth2-generic') {
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { render, cleanup, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import AuthCallback from '@app/routes/AuthCallback';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
|
||||
// Mock springAuth
|
||||
vi.mock('@app/auth/springAuthClient', () => ({
|
||||
springAuth: {
|
||||
getSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
@@ -21,38 +13,38 @@ vi.mock('react-router-dom', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useAuth hook
|
||||
const mockUseAuth = vi.fn();
|
||||
vi.mock('@app/auth/UseSession', () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
describe('AuthCallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
// Reset window.location.hash
|
||||
window.location.hash = '';
|
||||
// Default mock: no session, not loading
|
||||
mockUseAuth.mockReturnValue({ session: null, loading: false });
|
||||
});
|
||||
|
||||
it('should extract JWT from URL hash and validate it', async () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should extract JWT from URL hash and store it', async () => {
|
||||
const mockToken = 'oauth-jwt-token';
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: 'oauth@example.com',
|
||||
username: 'oauthuser',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
// Set URL hash with access token
|
||||
window.location.hash = `#access_token=${mockToken}`;
|
||||
|
||||
// Mock successful session validation
|
||||
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
session: {
|
||||
user: mockUser,
|
||||
access_token: mockToken,
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600000,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
// Mock useAuth returning loading state (validation in progress)
|
||||
mockUseAuth.mockReturnValue({ session: null, loading: true });
|
||||
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
@@ -62,21 +54,16 @@ describe('AuthCallback', () => {
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify JWT was stored
|
||||
expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
|
||||
// Advance timers to trigger the delayed tokenStored update (50ms delay)
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Verify jwt-available event was dispatched
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'jwt-available' })
|
||||
);
|
||||
// Verify JWT was stored
|
||||
expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
|
||||
|
||||
// Verify getSession was called to validate token
|
||||
expect(springAuth.getSession).toHaveBeenCalled();
|
||||
|
||||
// Verify navigation to home
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
|
||||
});
|
||||
// Verify jwt-available event was dispatched
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'jwt-available' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect to login when no access token in hash', async () => {
|
||||
@@ -89,89 +76,29 @@ describe('AuthCallback', () => {
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - no token received.' },
|
||||
});
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
// Advance timers to trigger the delayed navigation (2000ms delay in component)
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - no token received.' },
|
||||
});
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
});
|
||||
|
||||
it('should redirect to login when token validation fails', async () => {
|
||||
const invalidToken = 'invalid-oauth-token';
|
||||
window.location.hash = `#access_token=${invalidToken}`;
|
||||
|
||||
// Mock failed session validation
|
||||
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
|
||||
data: { session: null },
|
||||
error: { message: 'Invalid token' },
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// JWT should be stored initially
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull(); // Cleared after validation failure
|
||||
|
||||
// Verify redirect to login
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - invalid token.' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const mockToken = 'error-token';
|
||||
window.location.hash = `#access_token=${mockToken}`;
|
||||
|
||||
// Mock getSession throwing error
|
||||
vi.mocked(springAuth.getSession).mockRejectedValueOnce(
|
||||
new Error('Network error')
|
||||
);
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed. Please try again.' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display loading state while processing', () => {
|
||||
it('should display loading state initially', () => {
|
||||
window.location.hash = '#access_token=processing-token';
|
||||
|
||||
vi.mocked(springAuth.getSession).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
data: { session: null },
|
||||
error: { message: 'Token expired' },
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
// Mock useAuth returning loading state
|
||||
mockUseAuth.mockReturnValue({ session: null, loading: true });
|
||||
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(getByText('Completing authentication')).toBeInTheDocument();
|
||||
// Should show loading message
|
||||
expect(screen.getByText('Completing authentication')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,77 +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');
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - no token received.' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log('[AuthCallback] Handling OAuth/SAML callback...');
|
||||
console.debug('[AuthCallback] Current URL:', window.location.href);
|
||||
console.debug('[AuthCallback] Hash:', window.location.hash);
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[AuthCallback] JWT stored in localStorage');
|
||||
// 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');
|
||||
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
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.');
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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);
|
||||
setTimeout(() => {
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed. Please try again.' }
|
||||
state: { error: 'OAuth login failed - no token received.' }
|
||||
});
|
||||
}
|
||||
};
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
handleCallback();
|
||||
// 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: 'Authentication failed - please try again.' }
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
// If JWT still exists but no session, AuthProvider might still be initializing
|
||||
// Wait a bit more before giving up
|
||||
}
|
||||
}, [tokenStored, authLoading, session, navigate]);
|
||||
|
||||
// Timeout: If we've been validating for too long, show error
|
||||
useEffect(() => {
|
||||
if (status !== 'validating' || !tokenStored) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!session) {
|
||||
setStatus('error');
|
||||
setErrorMessage('Authentication timed out. Please try again.');
|
||||
|
||||
setTimeout(() => {
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'Authentication timed out. Please try again.' }
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [status, tokenStored, session, navigate]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
|
||||
@@ -61,6 +61,15 @@ vi.mock('@app/services/apiClient', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper to create mock API responses with TauriHttpResponse structure
|
||||
const createMockResponse = <T,>(data: T) => ({
|
||||
data,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config: {},
|
||||
});
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
const mockBackendProbeState = {
|
||||
status: 'up' as const,
|
||||
@@ -112,12 +121,10 @@ describe('Login', () => {
|
||||
});
|
||||
|
||||
// Mock apiClient for login UI data
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {},
|
||||
},
|
||||
});
|
||||
vi.mocked(apiClient.get).mockResolvedValue(createMockResponse({
|
||||
enableLogin: true,
|
||||
providerList: {},
|
||||
}));
|
||||
});
|
||||
|
||||
it('should render login form', async () => {
|
||||
@@ -264,14 +271,12 @@ describe('Login', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock provider list with authentik
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/authentik': 'Authentik',
|
||||
},
|
||||
vi.mocked(apiClient.get).mockResolvedValue(createMockResponse({
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/authentik': 'Authentik',
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
||||
error: null,
|
||||
@@ -307,14 +312,12 @@ describe('Login', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock provider list with custom provider 'mycompany'
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/mycompany': 'My Company SSO',
|
||||
},
|
||||
vi.mocked(apiClient.get).mockResolvedValue(createMockResponse({
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/mycompany': 'My Company SSO',
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
||||
error: null,
|
||||
@@ -351,14 +354,12 @@ describe('Login', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock provider list with 'oidc'
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/oidc': 'OIDC',
|
||||
},
|
||||
vi.mocked(apiClient.get).mockResolvedValue(createMockResponse({
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/oidc': 'OIDC',
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
||||
error: null,
|
||||
@@ -510,12 +511,10 @@ describe('Login', () => {
|
||||
it('should redirect to home when login disabled', async () => {
|
||||
mockBackendProbeState.loginDisabled = true;
|
||||
mockProbe.mockResolvedValueOnce({ status: 'up', loginDisabled: true, loading: false });
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: {
|
||||
enableLogin: false,
|
||||
providerList: {},
|
||||
},
|
||||
});
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(createMockResponse({
|
||||
enableLogin: false,
|
||||
providerList: {},
|
||||
}));
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
@@ -531,14 +530,12 @@ describe('Login', () => {
|
||||
});
|
||||
|
||||
it('should handle OAuth provider click', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/github': 'GitHub',
|
||||
},
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(createMockResponse({
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/github': 'GitHub',
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
||||
error: null,
|
||||
@@ -565,12 +562,10 @@ describe('Login', () => {
|
||||
});
|
||||
|
||||
it('should show email form by default when no SSO providers', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: {
|
||||
enableLogin: true,
|
||||
providerList: {}, // No providers
|
||||
},
|
||||
});
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(createMockResponse({
|
||||
enableLogin: true,
|
||||
providerList: {}, // No providers
|
||||
}));
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -142,7 +142,14 @@
|
||||
.oauth-container-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem; /* 12px */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oauth-container-vertical > div {
|
||||
width: 100%;
|
||||
max-width: 320rem; /* 320px - consistent width for all buttons */
|
||||
}
|
||||
|
||||
.oauth-button-icon {
|
||||
@@ -185,22 +192,24 @@
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 1rem; /* 12px 16px */
|
||||
border: 1px solid #d1d5db;
|
||||
justify-content: center; /* Center icon and text */
|
||||
padding: 0.875rem 1.25rem !important; /* 14px 20px - increased for better visibility */
|
||||
min-height: 3rem !important; /* 48px - ensure readable height */
|
||||
height: auto !important; /* Override Mantine default height */
|
||||
border: 1px solid #d1d5db !important;
|
||||
border-radius: 0.75rem; /* 12px */
|
||||
background-color: var(--auth-card-bg-light-only);
|
||||
background-color: var(--auth-card-bg-light-only) !important;
|
||||
font-size: 1rem; /* 16px */
|
||||
font-weight: 500;
|
||||
color: var(--auth-text-primary-light-only);
|
||||
color: var(--auth-text-primary-light-only) !important;
|
||||
cursor: pointer;
|
||||
gap: 0.75rem; /* 12px */
|
||||
gap: 1rem !important; /* 16px - increased spacing between icon and text */
|
||||
font-family: inherit;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.oauth-button-vertical:hover:not(:disabled) {
|
||||
background-color: #f3f4f6;
|
||||
background-color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
.oauth-button-vertical:disabled {
|
||||
@@ -243,12 +252,16 @@
|
||||
}
|
||||
|
||||
.oauth-icon-tiny {
|
||||
width: 1.25rem; /* 20px */
|
||||
height: 1.25rem; /* 20px */
|
||||
width: 1.5rem; /* 24px - increased for better visibility */
|
||||
height: 1.5rem; /* 24px */
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.oauth-button-vertical span {
|
||||
margin-left: 0.5rem; /* 8px - additional spacing between icon and text */
|
||||
}
|
||||
|
||||
/* Login Header Styles */
|
||||
.login-header {
|
||||
margin-bottom: 1rem; /* 16px */
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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