mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94da0519c4 | ||
|
|
5e1c73a6f5 | ||
|
|
299edf876e | ||
|
|
d92079f59e | ||
|
|
c008198e13 | ||
|
|
43df0bea37 | ||
|
|
9d895ae0ca | ||
|
|
dba8c1f962 | ||
|
|
ec72cfd7e2 | ||
|
|
817f43613f | ||
|
|
7e102cd08f | ||
|
|
c2a77ad90a | ||
|
|
c7996a5f55 | ||
|
|
67ab101a5b | ||
|
|
8cfc563381 | ||
|
|
8d6d0b0cdd | ||
|
|
7f4aa8db9b | ||
|
|
188d4c8db2 | ||
|
|
26eb133ced | ||
|
|
3acb96740a | ||
|
|
dd0686a680 | ||
|
|
40b6eb0389 | ||
|
|
7284c5225a | ||
|
|
41ae76eeda | ||
|
|
46a8d1bea8 | ||
|
|
aa742c3ed2 | ||
|
|
f498632d67 | ||
|
|
08b1f15677 | ||
|
|
a75af13304 | ||
|
|
a2ae30d260 | ||
|
|
df58816baf | ||
|
|
5f0652caca | ||
|
|
99c57bdf23 | ||
|
|
bf51895b8e |
+144
-36
@@ -214,75 +214,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,6 +434,7 @@ public class ApplicationProperties {
|
||||
private GoogleProvider google = new GoogleProvider();
|
||||
private GitHubProvider github = new GitHubProvider();
|
||||
private KeycloakProvider keycloak = new KeycloakProvider();
|
||||
private String endSessionEndpoint;
|
||||
|
||||
public Provider get(String registrationId) throws UnsupportedProviderException {
|
||||
return switch (registrationId.toLowerCase(Locale.ROOT)) {
|
||||
|
||||
@@ -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,7 +165,6 @@ 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(
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
|
||||
// enableLogin)
|
||||
|
||||
+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
|
||||
|
||||
@@ -46,6 +46,7 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-security'
|
||||
api 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
api 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
||||
api 'org.springframework.security:spring-security-oauth2-resource-server'
|
||||
api 'org.springframework.boot:spring-boot-starter-mail'
|
||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||
api 'com.github.ben-manes.caffeine:caffeine'
|
||||
|
||||
+12
-1
@@ -12,6 +12,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
@@ -210,7 +211,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
|
||||
@@ -395,6 +399,13 @@ public class ProprietaryUIDataController {
|
||||
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
username = detailsUser.getUsername();
|
||||
} else if (principal instanceof Jwt jwt) {
|
||||
username = jwt.getSubject();
|
||||
|
||||
switch (jwt.getClaimAsString("authType")) {
|
||||
case "OAUTH2" -> isOAuth2Login = true;
|
||||
case "SAML2" -> isSaml2Login = true;
|
||||
}
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
username = oAuth2User.getName();
|
||||
isOAuth2Login = true;
|
||||
|
||||
+295
-133
@@ -1,53 +1,53 @@
|
||||
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.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
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 org.springframework.web.client.RestClient;
|
||||
|
||||
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.model.AuthenticationType;
|
||||
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 static final Map<String, String> endSessionEndpointCache = new ConcurrentHashMap<>();
|
||||
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final LogoutSuccessHandler samlLogoutHandler;
|
||||
|
||||
private final AppConfig appConfig;
|
||||
|
||||
private final JwtServiceInterface jwtService;
|
||||
public CustomLogoutSuccessHandler(
|
||||
ApplicationProperties.Security securityProperties,
|
||||
LogoutSuccessHandler samlLogoutHandler) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.samlLogoutHandler = samlLogoutHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
|
||||
@@ -57,30 +57,25 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
|
||||
if (!response.isCommitted()) {
|
||||
if (authentication != null) {
|
||||
if (authentication instanceof Saml2Authentication samlAuthentication) {
|
||||
// Handle SAML2 logout redirection
|
||||
getRedirect_saml2(request, response, samlAuthentication);
|
||||
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
|
||||
// Handle OAuth2 logout redirection
|
||||
getRedirect_oauth2(request, response, oAuthToken);
|
||||
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
|
||||
// Handle Username/Password logout
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
} else {
|
||||
// Handle unknown authentication types
|
||||
log.error(
|
||||
"Authentication class unknown: {}",
|
||||
authentication.getClass().getSimpleName());
|
||||
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;
|
||||
// Extract authType claim and determine logout strategy
|
||||
String authType;
|
||||
if (authentication instanceof JwtAuthenticationToken jwtAuthToken) {
|
||||
authType =
|
||||
(String)
|
||||
jwtAuthToken
|
||||
.getToken()
|
||||
.getClaims()
|
||||
.getOrDefault("authType", AuthenticationType.WEB);
|
||||
log.debug("{} logout detected", authType);
|
||||
|
||||
switch (authType) {
|
||||
case "OAUTH2" -> handleOidcLogout(request, response, jwtAuthToken);
|
||||
case "SAML2" -> handleSamlLogout(request, response, jwtAuthToken);
|
||||
default ->
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Redirect to login page after logout
|
||||
String path = checkForErrors(request);
|
||||
getRedirectStrategy().sendRedirect(request, response, path);
|
||||
@@ -88,136 +83,303 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect for SAML2 authentication logout
|
||||
private void getRedirect_saml2(
|
||||
/** Handles SAML logout - either via IdP Single Logout (SLO) or local logout. */
|
||||
private void handleSamlLogout(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Saml2Authentication samlAuthentication)
|
||||
JwtAuthenticationToken jwtAuthenticationToken)
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (securityProperties.getSaml2().getEnableSingleLogout()) {
|
||||
log.info("SP-initiated SLO detected, logging out via IdP");
|
||||
|
||||
SAML2 samlConf = securityProperties.getSaml2();
|
||||
String registrationId = samlConf.getRegistrationId();
|
||||
// Reconstruct Saml2Authentication from JWT claims for SLO
|
||||
Optional<Saml2Authentication> reconstructedAuth =
|
||||
reconstructSaml2AuthenticationFromJwt(jwtAuthenticationToken);
|
||||
|
||||
CustomSaml2AuthenticatedPrincipal principal =
|
||||
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
|
||||
if (reconstructedAuth.isPresent()) {
|
||||
Saml2Authentication samlAuth = reconstructedAuth.get();
|
||||
|
||||
String nameIdValue = principal.name();
|
||||
if (samlLogoutHandler != null) {
|
||||
try {
|
||||
samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.error("SP-initiated SLO failed, falling back to local logout", e);
|
||||
}
|
||||
} else {
|
||||
log.warn(
|
||||
"SAML SLO enabled but handler not configured, performing local logout only");
|
||||
}
|
||||
}
|
||||
}
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param jwtAuthenticationToken The JWT authentication token containing SAML claims
|
||||
* @return Optional containing reconstructed Saml2Authentication, or empty if reconstruction
|
||||
* fails
|
||||
*/
|
||||
private Optional<Saml2Authentication> reconstructSaml2AuthenticationFromJwt(
|
||||
JwtAuthenticationToken jwtAuthenticationToken) {
|
||||
try {
|
||||
// Read certificate from the resource
|
||||
Resource certificateResource = samlConf.getSpCert();
|
||||
X509Certificate certificate = CertificateUtils.readCertificate(certificateResource);
|
||||
Map<String, Object> claims = jwtAuthenticationToken.getToken().getClaims();
|
||||
|
||||
List<X509Certificate> certificates = new ArrayList<>();
|
||||
certificates.add(certificate);
|
||||
// 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");
|
||||
|
||||
// Construct URLs required for SAML configuration
|
||||
SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates);
|
||||
if (nameId == null || registrationId == null) {
|
||||
log.debug(
|
||||
"Missing required SAML claims for SLO reconstruction: nameId={}, registrationId={}",
|
||||
nameId,
|
||||
registrationId);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Read private key for service provider
|
||||
Resource privateKeyResource = samlConf.getPrivateKey();
|
||||
RSAPrivateKey privateKey = CertificateUtils.readPrivateKey(privateKeyResource);
|
||||
List<String> sessionIndexes = Collections.emptyList();
|
||||
if (sessionIndexesObj instanceof List<?>) {
|
||||
sessionIndexes =
|
||||
((List<?>) sessionIndexesObj).stream().map(Object::toString).toList();
|
||||
}
|
||||
|
||||
// Set service provider keys for the SamlClient
|
||||
samlClient.setSPKeys(certificate, privateKey);
|
||||
// Create principal with all SAML attributes needed for SLO
|
||||
CustomSaml2AuthenticatedPrincipal principal =
|
||||
new CustomSaml2AuthenticatedPrincipal(
|
||||
username,
|
||||
Collections.emptyMap(), // Attributes not needed for logout
|
||||
nameId,
|
||||
sessionIndexes,
|
||||
registrationId);
|
||||
|
||||
// Build relay state to return user to login page after IdP logout
|
||||
String relayState =
|
||||
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
|
||||
// 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")));
|
||||
|
||||
// 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);
|
||||
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(
|
||||
// Redirect for JWT-based OAuth2 authentication logout
|
||||
private void handleOidcLogout(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
OAuth2AuthenticationToken oAuthToken)
|
||||
JwtAuthenticationToken jwtAuthenticationToken)
|
||||
throws IOException {
|
||||
String registrationId;
|
||||
OAUTH2 oauth = securityProperties.getOauth2();
|
||||
String path = checkForErrors(request);
|
||||
|
||||
String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path;
|
||||
registrationId = oAuthToken.getAuthorizedClientRegistrationId();
|
||||
boolean isApi = isApiRequest(request);
|
||||
|
||||
// Redirect based on OAuth2 provider
|
||||
switch (registrationId.toLowerCase(Locale.ROOT)) {
|
||||
case "keycloak" -> {
|
||||
String issuer = null;
|
||||
String clientId = null;
|
||||
|
||||
var jwtIssuer = jwtAuthenticationToken.getToken().getIssuer();
|
||||
if (jwtIssuer != null) {
|
||||
issuer = jwtIssuer.toString();
|
||||
log.debug("Using issuer from validated JWT token: {}", issuer);
|
||||
}
|
||||
|
||||
// Fallback: Use configured issuer if JWT doesn't contain one
|
||||
if (issuer == null) {
|
||||
if (oauth.getClient() != null && oauth.getClient().getKeycloak() != null) {
|
||||
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
|
||||
|
||||
boolean isKeycloak = !keycloak.getIssuer().isBlank();
|
||||
boolean isCustomOAuth = !oauth.getIssuer().isBlank();
|
||||
|
||||
String logoutUrl = redirectUrl;
|
||||
|
||||
if (isKeycloak) {
|
||||
logoutUrl = keycloak.getIssuer();
|
||||
} else if (isCustomOAuth) {
|
||||
logoutUrl = oauth.getIssuer();
|
||||
}
|
||||
if (isKeycloak || isCustomOAuth) {
|
||||
logoutUrl +=
|
||||
"/protocol/openid-connect/logout"
|
||||
+ "?client_id="
|
||||
+ oauth.getClientId()
|
||||
+ "&post_logout_redirect_uri="
|
||||
+ response.encodeRedirectURL(redirectUrl);
|
||||
log.info("Redirecting to Keycloak logout URL: {}", logoutUrl);
|
||||
} else {
|
||||
log.info(
|
||||
"No redirect URL for {} available. Redirecting to default logout URL:"
|
||||
+ " {}",
|
||||
registrationId,
|
||||
logoutUrl);
|
||||
if (keycloak.getIssuer() != null && !keycloak.getIssuer().isBlank()) {
|
||||
issuer = keycloak.getIssuer();
|
||||
}
|
||||
}
|
||||
if (issuer == null && oauth.getIssuer() != null && !oauth.getIssuer().isBlank()) {
|
||||
issuer = oauth.getIssuer();
|
||||
}
|
||||
if (issuer != null) {
|
||||
log.debug("Using issuer from configuration: {}", issuer);
|
||||
}
|
||||
}
|
||||
|
||||
if (oauth.getClient() != null && oauth.getClient().getKeycloak() != null) {
|
||||
clientId = oauth.getClient().getKeycloak().getClientId();
|
||||
}
|
||||
if (clientId == null && oauth.getClientId() != null) {
|
||||
clientId = oauth.getClientId();
|
||||
}
|
||||
|
||||
String endSessionEndpoint = getEndSessionEndpoint(oauth, issuer);
|
||||
|
||||
if (endSessionEndpoint != null) {
|
||||
StringBuilder logoutUrlBuilder = new StringBuilder(endSessionEndpoint);
|
||||
logoutUrlBuilder.append(endSessionEndpoint.contains("?") ? "&" : "?");
|
||||
|
||||
// Extract id_token from JWT claims for proper OIDC logout
|
||||
String idToken = (String) jwtAuthenticationToken.getToken().getClaims().get("id_token");
|
||||
if (idToken != null && !idToken.isBlank()) {
|
||||
logoutUrlBuilder.append("id_token_hint=").append(idToken).append("&");
|
||||
log.debug("Including id_token_hint in OIDC logout for proper SSO logout");
|
||||
}
|
||||
|
||||
// Use client_id and post_logout_redirect_uri
|
||||
if (clientId != null && !clientId.isBlank()) {
|
||||
logoutUrlBuilder.append("client_id=").append(clientId).append("&");
|
||||
}
|
||||
String encodedRedirectUri = URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8);
|
||||
logoutUrlBuilder.append("post_logout_redirect_uri=").append(encodedRedirectUri);
|
||||
|
||||
String logoutUrl = logoutUrlBuilder.toString();
|
||||
log.info("JWT-based OAuth2 logout URL: {}", logoutUrl);
|
||||
|
||||
// Return JSON for API requests, redirect for browser requests
|
||||
if (isApi) {
|
||||
sendJsonLogoutResponse(response, logoutUrl);
|
||||
} else {
|
||||
response.sendRedirect(logoutUrl);
|
||||
}
|
||||
case "github", "google" -> {
|
||||
log.info(
|
||||
"No redirect URL for {} available. Redirecting to default logout URL: {}",
|
||||
registrationId,
|
||||
redirectUrl);
|
||||
response.sendRedirect(redirectUrl);
|
||||
}
|
||||
default -> {
|
||||
log.info("Redirecting to default logout URL: {}", redirectUrl);
|
||||
} else {
|
||||
// No OIDC logout endpoint available - fallback to local logout
|
||||
log.info(
|
||||
"No OIDC logout endpoint available for issuer: {}. Using local logout: {}",
|
||||
issuer,
|
||||
redirectUrl);
|
||||
if (isApi) {
|
||||
sendJsonLogoutResponse(response, redirectUrl);
|
||||
} else {
|
||||
response.sendRedirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SamlClient getSamlClient(
|
||||
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
|
||||
throws SamlException {
|
||||
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
|
||||
/**
|
||||
* Gets the OIDC end_session_endpoint from: 1. Configuration first 2. Fall back to discovery 3.
|
||||
* Return null if not available
|
||||
*
|
||||
* @param oauth The OAuth2 configuration
|
||||
* @param issuer The OIDC issuer URL
|
||||
* @return The end_session_endpoint URL, or null if not available
|
||||
*/
|
||||
private String getEndSessionEndpoint(
|
||||
ApplicationProperties.Security.OAUTH2 oauth, String issuer) {
|
||||
if (oauth != null && oauth.getClient() != null) {
|
||||
String configuredEndpoint = oauth.getClient().getEndSessionEndpoint();
|
||||
|
||||
String relyingPartyIdentifier =
|
||||
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
|
||||
if (configuredEndpoint != null && !configuredEndpoint.isBlank()) {
|
||||
log.debug("Using configured end_session_endpoint: {}", configuredEndpoint);
|
||||
return configuredEndpoint;
|
||||
}
|
||||
}
|
||||
|
||||
String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId;
|
||||
if (issuer != null && !issuer.isBlank()) {
|
||||
return discoverEndSessionEndpoint(issuer);
|
||||
}
|
||||
|
||||
String idpSLOUrl = samlConf.getIdpSingleLogoutUrl();
|
||||
return null;
|
||||
}
|
||||
|
||||
String idpIssuer = samlConf.getIdpIssuer();
|
||||
/**
|
||||
* Discovers the OIDC end_session_endpoint from the provider's .well-known/openid-configuration
|
||||
* Uses a cache to avoid repeated HTTP calls
|
||||
*
|
||||
* @param issuer The OIDC issuer URL
|
||||
* @return The end_session_endpoint URL, or null if not found/supported
|
||||
*/
|
||||
private String discoverEndSessionEndpoint(String issuer) {
|
||||
if (endSessionEndpointCache.containsKey(issuer)) {
|
||||
return endSessionEndpointCache.get(issuer);
|
||||
}
|
||||
|
||||
// Create SamlClient instance for SAML logout
|
||||
return new SamlClient(
|
||||
relyingPartyIdentifier,
|
||||
assertionConsumerServiceUrl,
|
||||
idpSLOUrl,
|
||||
idpIssuer,
|
||||
certificates,
|
||||
SamlClient.SamlIdpBinding.POST);
|
||||
try {
|
||||
String discoveryUrl = issuer;
|
||||
if (!discoveryUrl.endsWith("/")) {
|
||||
discoveryUrl += "/";
|
||||
}
|
||||
discoveryUrl += ".well-known/openid-configuration";
|
||||
|
||||
log.debug("Discovery URL: {}", discoveryUrl);
|
||||
|
||||
RestClient restClient =
|
||||
RestClient.builder()
|
||||
.baseUrl(discoveryUrl)
|
||||
.defaultHeaders(headers -> headers.set("Accept", "application/json"))
|
||||
.build();
|
||||
|
||||
// Fetch and parse OIDC discovery document
|
||||
Map discoveryDoc =
|
||||
restClient
|
||||
.get()
|
||||
.retrieve()
|
||||
.onStatus(
|
||||
status -> !status.is2xxSuccessful(),
|
||||
(request, response) ->
|
||||
log.warn(
|
||||
"Failed to discover OIDC endpoints for {}: HTTP status {}",
|
||||
issuer,
|
||||
response.getStatusCode().value()))
|
||||
.body(Map.class);
|
||||
|
||||
if (discoveryDoc != null && discoveryDoc.containsKey("end_session_endpoint")) {
|
||||
String endpoint = (String) discoveryDoc.get("end_session_endpoint");
|
||||
if (endpoint != null && !endpoint.isBlank()) {
|
||||
log.info("Discovered end_session_endpoint : {}", endpoint);
|
||||
endSessionEndpointCache.put(issuer, endpoint);
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Provider {} does not advertise end_session_endpoint in OIDC discovery",
|
||||
issuer);
|
||||
// Cache null result to avoid repeated failed attempts
|
||||
endSessionEndpointCache.put(issuer, null);
|
||||
return null;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Error discovering end_session_endpoint for {}: {}", issuer, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if the request expects a JSON response (API/XHR request) */
|
||||
private boolean isApiRequest(HttpServletRequest request) {
|
||||
String accept = request.getHeader("Accept");
|
||||
String xRequestedWith = request.getHeader("X-Requested-With");
|
||||
return (accept != null && accept.contains("application/json"))
|
||||
|| "XMLHttpRequest".equals(xRequestedWith);
|
||||
}
|
||||
|
||||
/** Send JSON response with logout URL for API requests */
|
||||
private void sendJsonLogoutResponse(HttpServletResponse response, String logoutUrl)
|
||||
throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
response.setContentType("application/json");
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
// Escape the URL for JSON
|
||||
String escapedUrl = logoutUrl.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
response.getWriter().write("{\"logoutUrl\":\"" + escapedUrl + "\"}");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+43
-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,17 +194,17 @@ 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);
|
||||
|
||||
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 ->
|
||||
@@ -226,17 +224,36 @@ 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, 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
|
||||
@@ -360,9 +377,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
-25
@@ -31,6 +31,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.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
@@ -219,11 +220,30 @@ public class AuthController {
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
UserDetails userDetails = (UserDetails) auth.getPrincipal();
|
||||
User user = (User) userDetails;
|
||||
Object principal = auth.getPrincipal();
|
||||
|
||||
User user;
|
||||
if (principal instanceof User u) {
|
||||
user = u;
|
||||
} else {
|
||||
// JWT case - get User from Authority
|
||||
user =
|
||||
auth.getAuthorities().stream()
|
||||
.filter(Authority.class::isInstance)
|
||||
.map(Authority.class::cast)
|
||||
.findFirst()
|
||||
.map(Authority::getUser)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"User not found in authentication"));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("user", buildUserResponse(user)));
|
||||
|
||||
} catch (IllegalStateException e) {
|
||||
log.error("User not found in authentication context", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "User not found"));
|
||||
} catch (Exception e) {
|
||||
log.error("Get current user error", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
@@ -231,29 +251,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
|
||||
*
|
||||
@@ -507,6 +504,7 @@ public class AuthController {
|
||||
*/
|
||||
private Map<String, Object> buildUserResponse(User user) {
|
||||
Map<String, Object> userMap = new HashMap<>();
|
||||
|
||||
userMap.put("id", user.getId());
|
||||
userMap.put("email", user.getUsername()); // Use username as email
|
||||
userMap.put("username", user.getUsername());
|
||||
|
||||
+47
-8
@@ -8,25 +8,28 @@ import static stirling.software.proprietary.security.model.AuthenticationType.WE
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import io.jsonwebtoken.Jwts;
|
||||
|
||||
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;
|
||||
@@ -40,7 +43,6 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtServiceInterface jwtService;
|
||||
@@ -49,6 +51,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final AuthenticationEntryPoint authenticationEntryPoint;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
public JwtAuthenticationFilter(
|
||||
JwtServiceInterface jwtService,
|
||||
UserService userService,
|
||||
CustomUserDetailsService userDetailsService,
|
||||
AuthenticationEntryPoint authenticationEntryPoint,
|
||||
ApplicationProperties.Security securityProperties) {
|
||||
this.jwtService = jwtService;
|
||||
this.userService = userService;
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.authenticationEntryPoint = authenticationEntryPoint;
|
||||
this.securityProperties = securityProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
@@ -61,6 +76,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;
|
||||
@@ -106,7 +126,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String tokenUsername = claims.get("sub").toString();
|
||||
|
||||
try {
|
||||
authenticate(request, claims);
|
||||
authenticate(request, jwtToken, claims);
|
||||
} catch (SQLException | UnsupportedProviderException e) {
|
||||
log.error("Error processing user authentication for user: {}", tokenUsername, e);
|
||||
handleAuthenticationFailure(
|
||||
@@ -160,7 +180,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void authenticate(HttpServletRequest request, Map<String, Object> claims)
|
||||
private void authenticate(
|
||||
HttpServletRequest request, String jwtToken, Map<String, Object> claims)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
String username = claims.get("sub").toString();
|
||||
|
||||
@@ -169,12 +190,25 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
|
||||
if (userDetails != null) {
|
||||
UsernamePasswordAuthenticationToken authToken =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
convertTimestampToLong(claims, "iat");
|
||||
convertTimestampToLong(claims, "exp");
|
||||
convertTimestampToLong(claims, "nbf");
|
||||
|
||||
Jwt jwt =
|
||||
Jwt.withTokenValue(jwtToken)
|
||||
.headers(headers -> headers.put("alg", Jwts.SIG.RS256.getId()))
|
||||
.claims(claimsMap -> claimsMap.putAll(claims))
|
||||
.build();
|
||||
|
||||
JwtAuthenticationToken authToken =
|
||||
new JwtAuthenticationToken(jwt, 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);
|
||||
}
|
||||
@@ -208,6 +242,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
|
||||
private void convertTimestampToLong(Map<String, Object> claims, String claimName) {
|
||||
Long timestamp = (Long) claims.get(claimName);
|
||||
claims.put(claimName, Instant.ofEpochSecond(timestamp));
|
||||
}
|
||||
|
||||
private void handleAuthenticationFailure(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
|
||||
+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"),
|
||||
|
||||
+17
-6
@@ -14,6 +14,7 @@ import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
@@ -33,6 +34,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;
|
||||
@@ -68,8 +70,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
|
||||
// Check if user is eligible for OAuth (grandfathered or system has paid 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.isOAuthEligible(user)) {
|
||||
// User is not grandfathered and no paid license - block OAuth login
|
||||
@@ -148,11 +149,21 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
OAUTH2);
|
||||
}
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
// Build JWT claims with authType and optionally id_token for OIDC logout
|
||||
Map<String, Object> claims = new java.util.HashMap<>();
|
||||
claims.put("authType", AuthenticationType.OAUTH2);
|
||||
|
||||
// Store OIDC id_token for proper SSO logout
|
||||
if (principal instanceof OidcUser oidcUser) {
|
||||
String idToken = oidcUser.getIdToken().getTokenValue();
|
||||
if (idToken != null && !idToken.isBlank()) {
|
||||
claims.put("id_token", idToken);
|
||||
log.debug("Stored OIDC id_token in JWT claims for SSO logout");
|
||||
}
|
||||
}
|
||||
|
||||
String jwt = jwtService.generateToken(authentication, claims);
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
|
||||
-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 =
|
||||
"""
|
||||
|
||||
+21
-3
@@ -79,13 +79,14 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
|
||||
KeyPair keyPair = keyPairOpt.get();
|
||||
|
||||
Date now = new Date();
|
||||
var builder =
|
||||
Jwts.builder()
|
||||
.claims(claims)
|
||||
.subject(username)
|
||||
.issuer(ISSUER)
|
||||
.issuedAt(new Date())
|
||||
.issuedAt(now)
|
||||
.notBefore(now)
|
||||
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
|
||||
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
||||
|
||||
@@ -251,13 +252,30 @@ public class JwtService implements JwtServiceInterface {
|
||||
|
||||
@Override
|
||||
public String extractToken(HttpServletRequest request) {
|
||||
// Extract from Authorization header Bearer token
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+350
-164
@@ -1,44 +1,70 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.net.URL;
|
||||
import java.util.Map;
|
||||
|
||||
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.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClient.RequestHeadersUriSpec;
|
||||
import org.springframework.web.client.RestClient.ResponseSpec;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
|
||||
/**
|
||||
* Tests for CustomLogoutSuccessHandler.
|
||||
*
|
||||
* <p>The handler uses JWT-based authentication to determine logout strategy: - OAUTH2 authType:
|
||||
* Redirects to OIDC provider's end_session_endpoint - SAML2 authType: Delegates to SAML logout
|
||||
* handler - Other/null: Local logout redirect
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
@Mock private ApplicationProperties.Security securityProperties;
|
||||
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
@Mock private ApplicationProperties.Security.SAML2 saml2;
|
||||
|
||||
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
|
||||
@Mock private LogoutSuccessHandler logoutSuccessHandler;
|
||||
|
||||
private CustomLogoutSuccessHandler customLogoutSuccessHandler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
customLogoutSuccessHandler =
|
||||
new CustomLogoutSuccessHandler(securityProperties, logoutSuccessHandler);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessfulLogout() throws IOException {
|
||||
void testSuccessfulLogout_NullAuthentication() 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(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
|
||||
|
||||
@@ -46,29 +72,40 @@ class CustomLogoutSuccessHandlerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessfulLogoutViaJWT() throws IOException {
|
||||
void testJwtLogout_WebAuthType_RedirectsToLocalLogout() throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
|
||||
Jwt jwt = mock(Jwt.class);
|
||||
String logoutPath = "/login?logout=true";
|
||||
String token = "token";
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
|
||||
when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
when(jwt.getClaims()).thenReturn(Map.of("authType", "WEB"));
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
verify(response).sendRedirect(logoutPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessfulLogoutViaOAuth2() throws IOException {
|
||||
void testJwtLogout_OAuth2AuthType_WithConfiguredEndpoint_RedirectsToProvider()
|
||||
throws IOException {
|
||||
String issuerUrl = "https://keycloak.example.com/realms/test";
|
||||
String clientId = "stirling-pdf";
|
||||
String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken oAuth2AuthenticationToken = mock(OAuth2AuthenticationToken.class);
|
||||
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
|
||||
Jwt jwt = mock(Jwt.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
@@ -77,165 +114,286 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oAuth2AuthenticationToken.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
when(request.getHeader("Accept")).thenReturn("text/html");
|
||||
when(request.getHeader("X-Requested-With")).thenReturn(null);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, oAuth2AuthenticationToken);
|
||||
when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
|
||||
when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
|
||||
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
|
||||
when(oauth.getClientId()).thenReturn(clientId);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
verify(response).sendRedirect(contains(endSessionEndpoint));
|
||||
verify(response).sendRedirect(contains("client_id=" + clientId));
|
||||
verify(response).sendRedirect(contains("post_logout_redirect_uri="));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testJwtLogout_OAuth2AuthType_WithIdToken_IncludesIdTokenHint() throws IOException {
|
||||
String issuerUrl = "https://keycloak.example.com/realms/test";
|
||||
String clientId = "stirling-pdf";
|
||||
String idTokenValue = "test.id.token";
|
||||
String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
|
||||
Jwt jwt = mock(Jwt.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(request.getHeader("Accept")).thenReturn("text/html");
|
||||
when(request.getHeader("X-Requested-With")).thenReturn(null);
|
||||
|
||||
when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2", "id_token", idTokenValue));
|
||||
when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
|
||||
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
|
||||
when(oauth.getClientId()).thenReturn(clientId);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testJwtLogout_OAuth2AuthType_NoEndpoint_FallsBackToLocalLogout() throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
|
||||
Jwt jwt = mock(Jwt.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(request.getHeader("Accept")).thenReturn("text/html");
|
||||
when(request.getHeader("X-Requested-With")).thenReturn(null);
|
||||
|
||||
when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
|
||||
when(jwt.getIssuer()).thenReturn(null);
|
||||
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(null);
|
||||
when(client.getKeycloak()).thenReturn(null);
|
||||
when(oauth.getIssuer()).thenReturn("");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
verify(response).sendRedirect("http://localhost:8080/login?logout=true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUserIsDisabledRedirect() throws IOException {
|
||||
String error = "userIsDisabled";
|
||||
String url = "http://localhost:8080";
|
||||
void testJwtLogout_ApiRequest_ReturnsJsonWithLogoutUrl() throws IOException {
|
||||
String issuerUrl = "https://keycloak.example.com/realms/test";
|
||||
String clientId = "stirling-pdf";
|
||||
String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
|
||||
Jwt jwt = mock(Jwt.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
PrintWriter printWriter = new PrintWriter(stringWriter);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(request.getHeader("Accept")).thenReturn("application/json");
|
||||
when(request.getHeader("X-Requested-With")).thenReturn(null);
|
||||
when(response.getWriter()).thenReturn(printWriter);
|
||||
|
||||
when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
|
||||
when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
|
||||
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
|
||||
when(oauth.getClientId()).thenReturn(clientId);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
verify(response).setStatus(HttpServletResponse.SC_OK);
|
||||
verify(response).setContentType("application/json");
|
||||
verify(response).setCharacterEncoding("UTF-8");
|
||||
|
||||
String jsonResponse = stringWriter.toString();
|
||||
assert jsonResponse.contains("\"logoutUrl\":");
|
||||
assert jsonResponse.contains(issuerUrl);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testJwtLogout_XhrRequest_ReturnsJsonWithLogoutUrl() throws IOException {
|
||||
String issuerUrl = "https://keycloak.example.com/realms/test";
|
||||
String clientId = "stirling-pdf";
|
||||
String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
|
||||
Jwt jwt = mock(Jwt.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
PrintWriter printWriter = new PrintWriter(stringWriter);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(request.getHeader("Accept")).thenReturn("text/html");
|
||||
when(request.getHeader("X-Requested-With")).thenReturn("XMLHttpRequest");
|
||||
when(response.getWriter()).thenReturn(printWriter);
|
||||
|
||||
when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
|
||||
when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
|
||||
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
|
||||
when(oauth.getClientId()).thenReturn(clientId);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
verify(response).setStatus(HttpServletResponse.SC_OK);
|
||||
verify(response).setContentType("application/json");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testJwtLogout_Saml2AuthType_WithSloEnabled_LocalLogout() throws IOException {
|
||||
// When SLO is enabled but no SAML logout handler is configured, falls back to local logout
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
|
||||
Jwt jwt = mock(Jwt.class);
|
||||
String logoutPath = "/login?logout=true";
|
||||
|
||||
// Create handler with null samlLogoutHandler to test fallback behavior
|
||||
CustomLogoutSuccessHandler handlerWithoutSaml =
|
||||
new CustomLogoutSuccessHandler(securityProperties, null);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
|
||||
|
||||
when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
when(jwt.getClaims())
|
||||
.thenReturn(
|
||||
Map.of(
|
||||
"authType", "SAML2",
|
||||
"sub", "testuser",
|
||||
"samlNameId", "testuser@example.com",
|
||||
"samlRegistrationId", "test-idp"));
|
||||
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(true);
|
||||
|
||||
handlerWithoutSaml.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
// Falls back to local logout since no SAML handler is configured
|
||||
verify(response).sendRedirect(logoutPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testJwtLogout_Saml2AuthType_WithSloDisabled_LocalLogout() throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
|
||||
Jwt jwt = mock(Jwt.class);
|
||||
String logoutPath = "/login?logout=true";
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
|
||||
|
||||
when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
when(jwt.getClaims()).thenReturn(Map.of("authType", "SAML2"));
|
||||
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
verify(response).sendRedirect(logoutPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testErrorParameterHandling_UserIsDisabled() throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
String logoutPath = "/login?errorOAuth=userIsDisabled";
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
when(request.getParameter("userIsDisabled")).thenReturn("true");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
verify(response).sendRedirect(logoutPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUserAlreadyExistsWebRedirect() throws IOException {
|
||||
String error = "oAuth2AuthenticationErrorWeb";
|
||||
String errorPath = "userAlreadyExistsWeb";
|
||||
String url = "http://localhost:8080";
|
||||
void testErrorParameterHandling_BadCredentials() throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
String logoutPath = "/login?errorOAuth=badCredentials";
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + errorPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testErrorOAuthRedirect() throws IOException {
|
||||
String error = "testError";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn("!!!" + error + "!!!");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOAuth2AutoCreateDisabled() throws IOException {
|
||||
String error = "oAuth2AutoCreateDisabled";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).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);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOAuth2Error() throws IOException {
|
||||
String error = "test";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter("userIsDisabled")).thenReturn(null);
|
||||
when(request.getParameter("error")).thenReturn("!@$!@£" + error + "£$%^*$");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOAuth2BadCredentialsError() throws IOException {
|
||||
String error = "badCredentials";
|
||||
String url = "http://localhost:8080";
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
@@ -245,43 +403,71 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter("userIsDisabled")).thenReturn(null);
|
||||
when(request.getParameter("error")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
when(request.getParameter("badCredentials")).thenReturn("true");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
verify(response).sendRedirect(logoutPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOAuth2AdminBlockedUser() throws IOException {
|
||||
String error = "oAuth2AdminBlockedUser";
|
||||
String url = "http://localhost:8080";
|
||||
void testOidcDiscovery_CachesEndpoint() throws IOException {
|
||||
String issuerUrl = "https://authentik.example.com/application/o/stirling-pdf";
|
||||
String discoveredEndpoint = "https://authentik.example.com/application/o/end-session/";
|
||||
String clientId = "stirling-pdf";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
|
||||
Jwt jwt = mock(Jwt.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(request.getHeader("Accept")).thenReturn("text/html");
|
||||
when(request.getHeader("X-Requested-With")).thenReturn(null);
|
||||
|
||||
when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
|
||||
when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
|
||||
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(null);
|
||||
when(oauth.getClientId()).thenReturn(clientId);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
try (MockedStatic<RestClient> restClientStatic = mockStatic(RestClient.class)) {
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
RestClient.Builder mockBuilder = mock(RestClient.Builder.class);
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
RestClient mockRestClient = mock(RestClient.class);
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
RequestHeadersUriSpec mockRequestSpec = mock(RequestHeadersUriSpec.class);
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
ResponseSpec mockResponseSpec = mock(ResponseSpec.class);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
restClientStatic.when(RestClient::builder).thenReturn(mockBuilder);
|
||||
when(mockBuilder.baseUrl(anyString())).thenReturn(mockBuilder);
|
||||
when(mockBuilder.defaultHeaders(any())).thenReturn(mockBuilder);
|
||||
when(mockBuilder.build()).thenReturn(mockRestClient);
|
||||
when(mockRestClient.get()).thenReturn(mockRequestSpec);
|
||||
when(mockRequestSpec.retrieve()).thenReturn(mockResponseSpec);
|
||||
when(mockResponseSpec.onStatus(any(), any())).thenReturn(mockResponseSpec);
|
||||
|
||||
Map<String, Object> discoveryDoc = Map.of("end_session_endpoint", discoveredEndpoint);
|
||||
when(mockResponseSpec.body(Map.class)).thenReturn(discoveryDoc);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
verify(response).sendRedirect(contains(discoveredEndpoint));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -537,4 +537,4 @@ tasks.register('buildRestartHelper', Jar) {
|
||||
doLast {
|
||||
println "restart-helper.jar created at: ${destinationDirectory.get()}/restart-helper.jar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"posthog-js": "^1.268.0",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.3",
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -100,12 +100,21 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
// Helper to create mock API responses with full response structure
|
||||
const _createMockResponse = <T,>(data: T, status = 200, statusText = 'OK') => ({
|
||||
data,
|
||||
status,
|
||||
statusText,
|
||||
headers: {},
|
||||
config: {},
|
||||
});
|
||||
|
||||
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