mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-30 20:06:30 +01:00
Merge dbd899dc99 into 3529849bca
This commit is contained in:
commit
abd93d4fab
@ -325,6 +325,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()) {
|
||||
|
||||
@ -160,7 +160,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)
|
||||
|
||||
@ -40,6 +40,7 @@ security:
|
||||
issuer: '' # set to any Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint
|
||||
clientId: '' # client ID from your Provider
|
||||
clientSecret: '' # client secret from your Provider
|
||||
endSessionEndpoint: '' # Endpoint to initiate OIDC logout. Optional
|
||||
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
|
||||
useAsUsername: email # default is 'email'; custom fields can be used as the username
|
||||
|
||||
@ -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,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;
|
||||
|
||||
@ -371,6 +372,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;
|
||||
|
||||
@ -1,17 +1,24 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.oauth2.core.oidc.user.OidcUser;
|
||||
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.SimpleUrlLogoutSuccessHandler;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import com.coveo.saml.SamlClient;
|
||||
import com.coveo.saml.SamlException;
|
||||
@ -42,6 +49,8 @@ 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 AppConfig appConfig;
|
||||
@ -56,12 +65,13 @@ 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);
|
||||
if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
|
||||
getAuthRedirect(request, response, oAuthToken);
|
||||
} else if (authentication instanceof Saml2Authentication samlAuthentication) {
|
||||
getAuthRedirect(request, response, samlAuthentication);
|
||||
} else if (authentication
|
||||
instanceof JwtAuthenticationToken jwtAuthenticationToken) {
|
||||
getAuthRedirect(request, response);
|
||||
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
|
||||
// Handle Username/Password logout
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
@ -88,7 +98,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
}
|
||||
|
||||
// Redirect for SAML2 authentication logout
|
||||
private void getRedirect_saml2(
|
||||
private void getAuthRedirect(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Saml2Authentication samlAuthentication)
|
||||
@ -137,7 +147,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
}
|
||||
|
||||
// Redirect for OAuth2 authentication logout
|
||||
private void getRedirect_oauth2(
|
||||
private void getAuthRedirect(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
OAuth2AuthenticationToken oAuthToken)
|
||||
@ -151,50 +161,172 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
|
||||
// Redirect based on OAuth2 provider
|
||||
switch (registrationId.toLowerCase()) {
|
||||
case "keycloak" -> {
|
||||
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);
|
||||
}
|
||||
response.sendRedirect(logoutUrl);
|
||||
}
|
||||
case "github", "google" -> {
|
||||
// These providers don't support OIDC logout
|
||||
log.info(
|
||||
"No redirect URL for {} available. Redirecting to default logout URL: {}",
|
||||
"No logout URL for {} available. Redirecting to local logout: {}",
|
||||
registrationId,
|
||||
redirectUrl);
|
||||
response.sendRedirect(redirectUrl);
|
||||
}
|
||||
default -> {
|
||||
log.info("Redirecting to default logout URL: {}", redirectUrl);
|
||||
default -> handleOidcLogout(response, oAuthToken, oauth, redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect for JWT-based authentication logout
|
||||
private void getAuthRedirect(HttpServletRequest request, HttpServletResponse response)
|
||||
throws IOException {
|
||||
OAUTH2 oauth = securityProperties.getOauth2();
|
||||
String path = checkForErrors(request);
|
||||
String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path;
|
||||
boolean isApi = isApiRequest(request);
|
||||
|
||||
String issuer = null;
|
||||
String clientId = null;
|
||||
|
||||
if (oauth.getClient() != null && oauth.getClient().getKeycloak() != null) {
|
||||
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
|
||||
if (keycloak.getIssuer() != null && !keycloak.getIssuer().isBlank()) {
|
||||
issuer = keycloak.getIssuer();
|
||||
clientId = keycloak.getClientId();
|
||||
} else if (oauth.getIssuer() != null && !oauth.getIssuer().isBlank()) {
|
||||
issuer = oauth.getIssuer();
|
||||
clientId = oauth.getClientId();
|
||||
}
|
||||
} else if (oauth.getIssuer() != null && !oauth.getIssuer().isBlank()) {
|
||||
issuer = oauth.getIssuer();
|
||||
clientId = oauth.getClientId();
|
||||
}
|
||||
|
||||
String endSessionEndpoint = getEndSessionEndpoint(oauth, issuer);
|
||||
|
||||
if (endSessionEndpoint == null && issuer != null) {
|
||||
endSessionEndpoint = issuer + "/protocol/openid-connect/logout";
|
||||
log.debug("Using Keycloak fallback logout path: {}", endSessionEndpoint);
|
||||
}
|
||||
|
||||
if (endSessionEndpoint != null) {
|
||||
StringBuilder logoutUrlBuilder = new StringBuilder(endSessionEndpoint);
|
||||
logoutUrlBuilder.append(endSessionEndpoint.contains("?") ? "&" : "?");
|
||||
|
||||
// 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.debug("JWT-based OAuth2 logout URL: {}", logoutUrl);
|
||||
|
||||
// Return JSON for API requests, redirect for browser requests
|
||||
if (isApi) {
|
||||
sendJsonLogoutResponse(response, logoutUrl);
|
||||
} else {
|
||||
response.sendRedirect(logoutUrl);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles OIDC logout with hybrid endpoint discovery Tries: 1. Configured endpoint 2.
|
||||
* Discovered endpoint 3. Keycloak fallback (if isKeycloak=true) 4. Local logout
|
||||
*/
|
||||
private void handleOidcLogout(
|
||||
HttpServletResponse response,
|
||||
OAuth2AuthenticationToken oAuthToken,
|
||||
OAUTH2 oauth,
|
||||
String redirectUrl)
|
||||
throws IOException {
|
||||
|
||||
String issuer = null;
|
||||
String clientId = null;
|
||||
|
||||
boolean isKeycloak =
|
||||
"keycloak".equalsIgnoreCase(oAuthToken.getAuthorizedClientRegistrationId());
|
||||
if (isKeycloak) {
|
||||
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
|
||||
|
||||
if (keycloak.getIssuer() != null && !keycloak.getIssuer().isBlank()) {
|
||||
issuer = keycloak.getIssuer();
|
||||
clientId = keycloak.getClientId();
|
||||
} else if (oauth.getIssuer() != null && !oauth.getIssuer().isBlank()) {
|
||||
issuer = oauth.getIssuer();
|
||||
clientId = oauth.getClientId();
|
||||
}
|
||||
} else if (oauth.getIssuer() != null && !oauth.getIssuer().isBlank()) {
|
||||
issuer = oauth.getIssuer();
|
||||
clientId = oauth.getClientId();
|
||||
}
|
||||
|
||||
String endSessionEndpoint = getEndSessionEndpoint(oauth, issuer);
|
||||
|
||||
// If no endpoint found and this is Keycloak, try the hardcoded path
|
||||
if (endSessionEndpoint == null && isKeycloak && issuer != null) {
|
||||
endSessionEndpoint = issuer + "/protocol/openid-connect/logout";
|
||||
log.debug("Using Keycloak fallback logout path: {}", endSessionEndpoint);
|
||||
}
|
||||
|
||||
if (endSessionEndpoint != null) {
|
||||
StringBuilder logoutUrlBuilder = new StringBuilder(endSessionEndpoint);
|
||||
|
||||
// Extract id_token_hint if available
|
||||
Object principal = oAuthToken.getPrincipal();
|
||||
|
||||
if (principal instanceof OidcUser oidcUser) {
|
||||
String idToken = oidcUser.getIdToken().getTokenValue();
|
||||
logoutUrlBuilder
|
||||
.append(
|
||||
endSessionEndpoint.contains("?")
|
||||
? "&"
|
||||
: "?") // Handle existing params
|
||||
.append("id_token_hint=")
|
||||
.append(idToken)
|
||||
.append("&post_logout_redirect_uri=")
|
||||
.append(URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8));
|
||||
|
||||
// client_id is optional when id_token_hint is present, but included for
|
||||
// compatibility
|
||||
if (clientId != null && !clientId.isBlank()) {
|
||||
logoutUrlBuilder.append("&client_id=").append(clientId);
|
||||
}
|
||||
|
||||
log.info("Session-aware OIDC logout: {}", endSessionEndpoint);
|
||||
} else {
|
||||
logoutUrlBuilder.append(endSessionEndpoint.contains("?") ? "&" : "?");
|
||||
if (clientId != null && !clientId.isBlank()) {
|
||||
logoutUrlBuilder.append("client_id=").append(clientId).append("&");
|
||||
}
|
||||
logoutUrlBuilder
|
||||
.append("post_logout_redirect_uri=")
|
||||
.append(URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String logoutUrl = logoutUrlBuilder.toString();
|
||||
log.debug("OIDC logout URL: {}", logoutUrl);
|
||||
|
||||
response.sendRedirect(logoutUrl);
|
||||
} else {
|
||||
// No OIDC logout endpoint available - fallback to local logout
|
||||
log.info(
|
||||
"No OIDC logout endpoint available for issuer: {}. Using local logout: {}",
|
||||
issuer,
|
||||
redirectUrl);
|
||||
response.sendRedirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private SamlClient getSamlClient(
|
||||
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
|
||||
throws SamlException {
|
||||
@ -219,6 +351,114 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
SamlClient.SamlIdpBinding.POST);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
if (configuredEndpoint != null && !configuredEndpoint.isBlank()) {
|
||||
log.debug("Using configured end_session_endpoint: {}", configuredEndpoint);
|
||||
return configuredEndpoint;
|
||||
}
|
||||
}
|
||||
|
||||
if (issuer != null && !issuer.isBlank()) {
|
||||
return discoverEndSessionEndpoint(issuer);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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 + "\"}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles different error scenarios during logout. Will return a <code>String</code> containing
|
||||
* the error request parameter.
|
||||
|
||||
@ -23,6 +23,7 @@ import org.springframework.security.saml2.provider.service.registration.RelyingP
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutFilter;
|
||||
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
|
||||
import org.springframework.security.web.savedrequest.NullRequestCache;
|
||||
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
||||
@ -201,7 +202,7 @@ public class SecurityConfiguration {
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
||||
.addFilterBefore(jwtAuthenticationFilter, LogoutFilter.class);
|
||||
|
||||
http.sessionManagement(
|
||||
sessionManagement ->
|
||||
|
||||
@ -25,6 +25,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.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
@ -155,11 +156,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)
|
||||
@ -167,29 +187,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
|
||||
*
|
||||
@ -239,6 +236,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());
|
||||
|
||||
@ -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)
|
||||
@ -106,7 +121,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 +175,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,9 +185,18 @@ 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));
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
@ -208,6 +233,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,
|
||||
|
||||
@ -36,6 +36,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;
|
||||
@ -74,8 +75,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
|
||||
@ -154,7 +154,6 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
OAUTH2);
|
||||
}
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
|
||||
@ -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,14 +252,10 @@ 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;
|
||||
}
|
||||
|
||||
return null;
|
||||
return (authHeader != null && authHeader.startsWith("Bearer "))
|
||||
? authHeader.substring(7)
|
||||
: null;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -1,23 +1,46 @@
|
||||
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.lenient;
|
||||
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.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
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.core.io.Resource;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
|
||||
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.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.oauth2.KeycloakProvider;
|
||||
import stirling.software.proprietary.security.saml2.CertificateUtils;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@ -81,6 +104,7 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
|
||||
when(oAuth2AuthenticationToken.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, oAuth2AuthenticationToken);
|
||||
@ -112,6 +136,7 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
@ -137,6 +162,7 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
@ -162,6 +188,7 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
@ -189,6 +216,7 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
@ -221,6 +249,7 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
@ -254,6 +283,7 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
@ -281,10 +311,876 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getIssuer()).thenReturn(""); // No issuer configured - will use local logout
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testKeycloakLogoutWithOidcUser_IncludesIdTokenHint() throws IOException {
|
||||
// Test that Keycloak logout with OidcUser includes id_token_hint parameter
|
||||
String idTokenValue = "test.id.token";
|
||||
String issuerUrl = "https://keycloak.example.com/realms/test";
|
||||
String clientId = "stirling-pdf";
|
||||
String redirectUrl = "http://localhost:8080/login?logout=true";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
KeycloakProvider keycloakProvider = mock(KeycloakProvider.class);
|
||||
|
||||
// Create OidcUser with id token
|
||||
OidcIdToken idToken =
|
||||
new OidcIdToken(
|
||||
idTokenValue,
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600),
|
||||
java.util.Map.of("sub", "user123"));
|
||||
OidcUser oidcUser = mock(OidcUser.class);
|
||||
when(oidcUser.getIdToken()).thenReturn(idToken);
|
||||
|
||||
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(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(null); // Not configured
|
||||
when(client.getKeycloak()).thenReturn(keycloakProvider);
|
||||
when(keycloakProvider.getIssuer()).thenReturn(issuerUrl);
|
||||
when(keycloakProvider.getClientId()).thenReturn(clientId);
|
||||
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("keycloak");
|
||||
when(authentication.getPrincipal()).thenReturn(oidcUser);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify the logout URL contains id_token_hint
|
||||
// Note: With new logic, uses Keycloak fallback path
|
||||
verify(response).sendRedirect(contains(issuerUrl + "/protocol/openid-connect/logout"));
|
||||
verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
|
||||
verify(response).sendRedirect(contains("post_logout_redirect_uri="));
|
||||
verify(response).sendRedirect(contains("client_id=" + clientId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testKeycloakLogoutWithoutOidcUser_FallsBackToClientId() throws IOException {
|
||||
// Test that Keycloak logout without OidcUser falls back to client_id only
|
||||
String issuerUrl = "https://keycloak.example.com/realms/test";
|
||||
String clientId = "stirling-pdf";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
KeycloakProvider keycloakProvider = mock(KeycloakProvider.class);
|
||||
|
||||
// Create non-OIDC OAuth2User (no id token available)
|
||||
OAuth2User oauth2User = mock(OAuth2User.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(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(null); // Not configured
|
||||
when(client.getKeycloak()).thenReturn(keycloakProvider);
|
||||
when(keycloakProvider.getIssuer()).thenReturn(issuerUrl);
|
||||
when(keycloakProvider.getClientId()).thenReturn(clientId);
|
||||
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("keycloak");
|
||||
when(authentication.getPrincipal()).thenReturn(oauth2User);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify the logout URL uses client_id without id_token_hint
|
||||
verify(response).sendRedirect(contains("/protocol/openid-connect/logout"));
|
||||
verify(response).sendRedirect(contains("client_id=" + clientId));
|
||||
verify(response).sendRedirect(contains("post_logout_redirect_uri="));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testKeycloakLogoutWithCustomOAuth_UsesCustomIssuer() throws IOException {
|
||||
// Test that custom OAuth provider uses custom issuer URL
|
||||
String customIssuerUrl = "https://custom-oauth.example.com";
|
||||
String clientId = "stirling-pdf";
|
||||
String idTokenValue = "custom.id.token";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
KeycloakProvider keycloakProvider = mock(KeycloakProvider.class);
|
||||
|
||||
// Create OidcUser with id token
|
||||
OidcIdToken idToken =
|
||||
new OidcIdToken(
|
||||
idTokenValue,
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600),
|
||||
java.util.Map.of("sub", "user123"));
|
||||
OidcUser oidcUser = mock(OidcUser.class);
|
||||
when(oidcUser.getIdToken()).thenReturn(idToken);
|
||||
|
||||
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(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(null); // Not configured
|
||||
when(client.getKeycloak()).thenReturn(keycloakProvider);
|
||||
when(keycloakProvider.getIssuer()).thenReturn(""); // Empty keycloak issuer
|
||||
when(oauth.getIssuer()).thenReturn(customIssuerUrl); // Use custom issuer
|
||||
when(oauth.getClientId()).thenReturn(clientId);
|
||||
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("keycloak");
|
||||
when(authentication.getPrincipal()).thenReturn(oidcUser);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify custom issuer is used with Keycloak fallback path
|
||||
verify(response)
|
||||
.sendRedirect(contains(customIssuerUrl + "/protocol/openid-connect/logout"));
|
||||
verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGitHubLogout_RedirectsToLocalLogout() throws IOException {
|
||||
// Test that GitHub logout redirects to local logout page (no provider logout)
|
||||
String redirectUrl = "http://localhost:8080/login?logout=true";
|
||||
|
||||
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.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("github");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify redirect to local logout page
|
||||
verify(response).sendRedirect(redirectUrl);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGoogleLogout_RedirectsToLocalLogout() throws IOException {
|
||||
// Test that Google logout redirects to local logout page (no provider logout)
|
||||
String redirectUrl = "http://localhost:8080/login?logout=true";
|
||||
|
||||
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.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("google");
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify redirect to local logout page
|
||||
verify(response).sendRedirect(redirectUrl);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaml2LogoutSuccess_RedirectsToIdentityProvider() throws Exception {
|
||||
// Test successful SAML2 logout with redirect to IdP
|
||||
// Note: This test verifies the handler processes SAML2 logout without exceptions
|
||||
// In a real scenario, SamlClient would redirect to the IdP
|
||||
String registrationId = "test-saml";
|
||||
String providerName = "TestIdP";
|
||||
String nameIdValue = "user@example.com";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
Saml2Authentication authentication = mock(Saml2Authentication.class);
|
||||
CustomSaml2AuthenticatedPrincipal principal = mock(CustomSaml2AuthenticatedPrincipal.class);
|
||||
ApplicationProperties.Security.SAML2 saml2Config =
|
||||
mock(ApplicationProperties.Security.SAML2.class);
|
||||
|
||||
Resource certResource = mock(Resource.class);
|
||||
Resource keyResource = mock(Resource.class);
|
||||
X509Certificate certificate = mock(X509Certificate.class);
|
||||
RSAPrivateKey privateKey = mock(RSAPrivateKey.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2Config);
|
||||
when(saml2Config.getRegistrationId()).thenReturn(registrationId);
|
||||
when(saml2Config.getProvider()).thenReturn(providerName);
|
||||
when(saml2Config.getSpCert()).thenReturn(certResource);
|
||||
when(saml2Config.getPrivateKey()).thenReturn(keyResource);
|
||||
when(saml2Config.getIdpSingleLogoutUrl()).thenReturn("https://idp.example.com/logout");
|
||||
when(saml2Config.getIdpIssuer()).thenReturn("https://idp.example.com");
|
||||
|
||||
when(authentication.getPrincipal()).thenReturn(principal);
|
||||
when(principal.name()).thenReturn(nameIdValue);
|
||||
|
||||
when(appConfig.getBaseUrl()).thenReturn("http://localhost");
|
||||
when(appConfig.getServerPort()).thenReturn("8080");
|
||||
|
||||
// Use static mocking for CertificateUtils
|
||||
try (MockedStatic<CertificateUtils> certUtils = mockStatic(CertificateUtils.class)) {
|
||||
certUtils
|
||||
.when(() -> CertificateUtils.readCertificate(certResource))
|
||||
.thenReturn(certificate);
|
||||
certUtils
|
||||
.when(() -> CertificateUtils.readPrivateKey(keyResource))
|
||||
.thenReturn(privateKey);
|
||||
|
||||
// This should complete without throwing an exception
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
// Success is verified by no exception being thrown
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaml2LogoutFailure_FallsBackToLocalLogout() throws Exception {
|
||||
// Test SAML2 logout with exception falls back to local logout
|
||||
String registrationId = "test-saml";
|
||||
String providerName = "TestIdP";
|
||||
String nameIdValue = "user@example.com";
|
||||
String localLogoutPath = "/login?logout=true";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
Saml2Authentication authentication = mock(Saml2Authentication.class);
|
||||
CustomSaml2AuthenticatedPrincipal principal = mock(CustomSaml2AuthenticatedPrincipal.class);
|
||||
ApplicationProperties.Security.SAML2 saml2Config =
|
||||
mock(ApplicationProperties.Security.SAML2.class);
|
||||
|
||||
Resource certResource = mock(Resource.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(response.encodeRedirectURL(anyString())).thenAnswer(i -> i.getArguments()[0]);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2Config);
|
||||
when(saml2Config.getRegistrationId()).thenReturn(registrationId);
|
||||
when(saml2Config.getProvider()).thenReturn(providerName);
|
||||
when(saml2Config.getSpCert()).thenReturn(certResource);
|
||||
|
||||
when(authentication.getPrincipal()).thenReturn(principal);
|
||||
when(principal.name()).thenReturn(nameIdValue);
|
||||
|
||||
// Simulate exception when reading certificate
|
||||
try (MockedStatic<CertificateUtils> certUtils = mockStatic(CertificateUtils.class)) {
|
||||
certUtils
|
||||
.when(() -> CertificateUtils.readCertificate(certResource))
|
||||
.thenThrow(new RuntimeException("Failed to read certificate"));
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify fallback to local logout via redirect strategy
|
||||
verify(response).sendRedirect(localLogoutPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaml2LogoutWithCertificateError_RedirectsToLocalLogout() throws Exception {
|
||||
// Test SAML2 logout with certificate reading error
|
||||
String registrationId = "test-saml";
|
||||
String providerName = "TestIdP";
|
||||
String nameIdValue = "user@example.com";
|
||||
String localLogoutPath = "/login?logout=true";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
Saml2Authentication authentication = mock(Saml2Authentication.class);
|
||||
CustomSaml2AuthenticatedPrincipal principal = mock(CustomSaml2AuthenticatedPrincipal.class);
|
||||
ApplicationProperties.Security.SAML2 saml2Config =
|
||||
mock(ApplicationProperties.Security.SAML2.class);
|
||||
|
||||
Resource certResource = mock(Resource.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(response.encodeRedirectURL(anyString())).thenAnswer(i -> i.getArguments()[0]);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2Config);
|
||||
when(saml2Config.getRegistrationId()).thenReturn(registrationId);
|
||||
when(saml2Config.getProvider()).thenReturn(providerName);
|
||||
when(saml2Config.getSpCert()).thenReturn(certResource);
|
||||
|
||||
when(authentication.getPrincipal()).thenReturn(principal);
|
||||
when(principal.name()).thenReturn(nameIdValue);
|
||||
|
||||
// Simulate certificate error
|
||||
try (MockedStatic<CertificateUtils> certUtils = mockStatic(CertificateUtils.class)) {
|
||||
certUtils
|
||||
.when(() -> CertificateUtils.readCertificate(certResource))
|
||||
.thenThrow(
|
||||
new java.security.cert.CertificateException(
|
||||
"Invalid certificate format"));
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify fallback to local logout via redirect strategy
|
||||
verify(response).sendRedirect(localLogoutPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaml2LogoutWithPrivateKeyError_RedirectsToLocalLogout() throws Exception {
|
||||
// Test SAML2 logout with private key reading error
|
||||
String registrationId = "test-saml";
|
||||
String providerName = "TestIdP";
|
||||
String nameIdValue = "user@example.com";
|
||||
String localLogoutPath = "/login?logout=true";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
Saml2Authentication authentication = mock(Saml2Authentication.class);
|
||||
CustomSaml2AuthenticatedPrincipal principal = mock(CustomSaml2AuthenticatedPrincipal.class);
|
||||
ApplicationProperties.Security.SAML2 saml2Config =
|
||||
mock(ApplicationProperties.Security.SAML2.class);
|
||||
|
||||
Resource certResource = mock(Resource.class);
|
||||
Resource keyResource = mock(Resource.class);
|
||||
X509Certificate certificate = mock(X509Certificate.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(response.encodeRedirectURL(anyString())).thenAnswer(i -> i.getArguments()[0]);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2Config);
|
||||
when(saml2Config.getRegistrationId()).thenReturn(registrationId);
|
||||
when(saml2Config.getProvider()).thenReturn(providerName);
|
||||
when(saml2Config.getSpCert()).thenReturn(certResource);
|
||||
when(saml2Config.getPrivateKey()).thenReturn(keyResource);
|
||||
when(saml2Config.getIdpSingleLogoutUrl()).thenReturn("https://idp.example.com/logout");
|
||||
when(saml2Config.getIdpIssuer()).thenReturn("https://idp.example.com");
|
||||
|
||||
when(authentication.getPrincipal()).thenReturn(principal);
|
||||
when(principal.name()).thenReturn(nameIdValue);
|
||||
|
||||
when(appConfig.getBaseUrl()).thenReturn("http://localhost");
|
||||
when(appConfig.getServerPort()).thenReturn("8080");
|
||||
|
||||
// Certificate reads successfully but private key fails
|
||||
try (MockedStatic<CertificateUtils> certUtils = mockStatic(CertificateUtils.class)) {
|
||||
certUtils
|
||||
.when(() -> CertificateUtils.readCertificate(certResource))
|
||||
.thenReturn(certificate);
|
||||
certUtils
|
||||
.when(() -> CertificateUtils.readPrivateKey(keyResource))
|
||||
.thenThrow(new RuntimeException("Failed to read private key"));
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify fallback to local logout via redirect strategy
|
||||
verify(response).sendRedirect(localLogoutPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenericOidcProvider_WithConfiguredEndpoint_SkipsDiscovery() throws IOException {
|
||||
// Test that configured endSessionEndpoint takes priority over discovery
|
||||
String configuredEndpoint = "https://authentik.example.com/application/o/end-session/";
|
||||
String issuerUrl = "https://authentik.example.com/application/o/stirling-pdf/";
|
||||
String clientId = "stirling-pdf";
|
||||
String idTokenValue = "test.id.token";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
|
||||
// Create OidcUser with id token
|
||||
OidcIdToken idToken =
|
||||
new OidcIdToken(
|
||||
idTokenValue,
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600),
|
||||
java.util.Map.of("sub", "user123"));
|
||||
OidcUser oidcUser = mock(OidcUser.class);
|
||||
when(oidcUser.getIdToken()).thenReturn(idToken);
|
||||
|
||||
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(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint())
|
||||
.thenReturn(configuredEndpoint); // Configured endpoint provided
|
||||
when(oauth.getIssuer()).thenReturn(issuerUrl);
|
||||
when(oauth.getClientId()).thenReturn(clientId);
|
||||
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("authentik");
|
||||
when(authentication.getPrincipal()).thenReturn(oidcUser);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify configured endpoint is used (no discovery should happen)
|
||||
verify(response).sendRedirect(contains(configuredEndpoint));
|
||||
verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
|
||||
verify(response).sendRedirect(contains("post_logout_redirect_uri="));
|
||||
verify(response).sendRedirect(contains("client_id=" + clientId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenericOidcProvider_WithSuccessfulDiscovery() throws IOException {
|
||||
// Test that generic OIDC provider uses discovered endpoint
|
||||
String issuerUrl = "https://authentik.example.com/application/o/stirling-pdf";
|
||||
String discoveredEndpoint = "https://authentik.example.com/application/o/end-session/";
|
||||
String clientId = "stirling-pdf";
|
||||
String idTokenValue = "test.id.token";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
|
||||
// Create OidcUser with id token
|
||||
OidcIdToken idToken =
|
||||
new OidcIdToken(
|
||||
idTokenValue,
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600),
|
||||
java.util.Map.of("sub", "user123"));
|
||||
OidcUser oidcUser = mock(OidcUser.class);
|
||||
when(oidcUser.getIdToken()).thenReturn(idToken);
|
||||
|
||||
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(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(null); // No configured endpoint
|
||||
when(oauth.getIssuer()).thenReturn(issuerUrl);
|
||||
when(oauth.getClientId()).thenReturn(clientId);
|
||||
|
||||
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("authentik");
|
||||
when(authentication.getPrincipal()).thenReturn(oidcUser);
|
||||
|
||||
// Mock RestClient for discovery
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
// Mock discovery document response
|
||||
Map<String, Object> discoveryDoc = Map.of("end_session_endpoint", discoveredEndpoint);
|
||||
when(mockResponseSpec.body(Map.class)).thenReturn(discoveryDoc);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify discovered endpoint is used
|
||||
verify(response).sendRedirect(contains(discoveredEndpoint));
|
||||
verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
|
||||
verify(response).sendRedirect(contains("post_logout_redirect_uri="));
|
||||
verify(response).sendRedirect(contains("client_id=" + clientId));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenericOidcProvider_WithFailedDiscovery_FallsBackToLocalLogout() throws IOException {
|
||||
// Test that failed discovery falls back to local logout
|
||||
String issuerUrl = "https://cloudron.example.com";
|
||||
String clientId = "stirling-pdf";
|
||||
String redirectUrl = "http://localhost:8080/login?logout=true";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
|
||||
OAuth2User oauth2User = mock(OAuth2User.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(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
lenient().when(oauth.getClient()).thenReturn(client);
|
||||
lenient().when(client.getEndSessionEndpoint()).thenReturn(null); // No configured endpoint
|
||||
lenient().when(oauth.getIssuer()).thenReturn(issuerUrl);
|
||||
lenient().when(oauth.getClientId()).thenReturn(clientId);
|
||||
|
||||
lenient().when(authentication.getAuthorizedClientRegistrationId()).thenReturn("cloudron");
|
||||
lenient().when(authentication.getPrincipal()).thenReturn(oauth2User);
|
||||
|
||||
// Mock RestClient for failed discovery
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
// Mock discovery document without end_session_endpoint
|
||||
Map<String, Object> discoveryDoc =
|
||||
Map.of("issuer", issuerUrl, "authorization_endpoint", issuerUrl + "/oauth");
|
||||
when(mockResponseSpec.body(Map.class)).thenReturn(discoveryDoc);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify fallback to local logout
|
||||
verify(response).sendRedirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenericOidcProvider_WithDiscoveryException_FallsBackToLocalLogout()
|
||||
throws IOException {
|
||||
// Test that discovery exceptions fall back to local logout
|
||||
String issuerUrl = "https://broken.example.com";
|
||||
String redirectUrl = "http://localhost:8080/login?logout=true";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
|
||||
OAuth2User oauth2User = mock(OAuth2User.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(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
lenient().when(oauth.getClient()).thenReturn(client);
|
||||
lenient().when(client.getEndSessionEndpoint()).thenReturn(null);
|
||||
lenient().when(oauth.getIssuer()).thenReturn(issuerUrl);
|
||||
|
||||
lenient().when(authentication.getAuthorizedClientRegistrationId()).thenReturn("broken");
|
||||
lenient().when(authentication.getPrincipal()).thenReturn(oauth2User);
|
||||
|
||||
// Mock RestClient to throw exception
|
||||
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);
|
||||
|
||||
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);
|
||||
when(mockResponseSpec.body(Map.class)).thenThrow(new RuntimeException("Network error"));
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
|
||||
|
||||
// Verify fallback to local logout
|
||||
verify(response).sendRedirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testJwtLogout_ApiRequest_ReturnsJsonWithLogoutUrl() throws IOException {
|
||||
// Test that API requests (Accept: application/json) get JSON response with logout URL
|
||||
String issuerUrl = "https://keycloak.example.com/realms/test";
|
||||
String clientId = "stirling-pdf";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
|
||||
jwtAuth =
|
||||
mock(
|
||||
org.springframework.security.oauth2.server.resource.authentication
|
||||
.JwtAuthenticationToken.class);
|
||||
org.springframework.security.oauth2.jwt.Jwt jwt =
|
||||
mock(org.springframework.security.oauth2.jwt.Jwt.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
KeycloakProvider keycloakProvider = mock(KeycloakProvider.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"); // API request
|
||||
when(request.getHeader("X-Requested-With")).thenReturn(null);
|
||||
when(response.getWriter()).thenReturn(printWriter);
|
||||
|
||||
lenient().when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
lenient().when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
|
||||
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(null);
|
||||
when(client.getKeycloak()).thenReturn(keycloakProvider);
|
||||
when(keycloakProvider.getIssuer()).thenReturn(issuerUrl);
|
||||
when(keycloakProvider.getClientId()).thenReturn(clientId);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
// Verify JSON response
|
||||
verify(response).setStatus(HttpServletResponse.SC_OK);
|
||||
verify(response).setContentType("application/json");
|
||||
verify(response).setCharacterEncoding("UTF-8");
|
||||
verify(response).getWriter();
|
||||
|
||||
String jsonResponse = stringWriter.toString();
|
||||
assert jsonResponse.contains("\"logoutUrl\":");
|
||||
assert jsonResponse.contains(issuerUrl);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testJwtLogout_XhrRequest_ReturnsJsonWithLogoutUrl() throws IOException {
|
||||
// Test that XHR requests (X-Requested-With: XMLHttpRequest) get JSON response
|
||||
String issuerUrl = "https://keycloak.example.com/realms/test";
|
||||
String clientId = "stirling-pdf";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
|
||||
jwtAuth =
|
||||
mock(
|
||||
org.springframework.security.oauth2.server.resource.authentication
|
||||
.JwtAuthenticationToken.class);
|
||||
org.springframework.security.oauth2.jwt.Jwt jwt =
|
||||
mock(org.springframework.security.oauth2.jwt.Jwt.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
KeycloakProvider keycloakProvider = mock(KeycloakProvider.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"); // Not JSON Accept header
|
||||
when(request.getHeader("X-Requested-With")).thenReturn("XMLHttpRequest"); // XHR request
|
||||
when(response.getWriter()).thenReturn(printWriter);
|
||||
|
||||
lenient().when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
lenient().when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
|
||||
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(null);
|
||||
when(client.getKeycloak()).thenReturn(keycloakProvider);
|
||||
when(keycloakProvider.getIssuer()).thenReturn(issuerUrl);
|
||||
when(keycloakProvider.getClientId()).thenReturn(clientId);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
// Verify JSON response
|
||||
verify(response).setStatus(HttpServletResponse.SC_OK);
|
||||
verify(response).setContentType("application/json");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testJwtLogout_BrowserRequest_RedirectsToLogoutUrl() throws IOException {
|
||||
// Test that browser requests (no Accept: application/json) get redirected
|
||||
String issuerUrl = "https://keycloak.example.com/realms/test";
|
||||
String clientId = "stirling-pdf";
|
||||
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
|
||||
jwtAuth =
|
||||
mock(
|
||||
org.springframework.security.oauth2.server.resource.authentication
|
||||
.JwtAuthenticationToken.class);
|
||||
org.springframework.security.oauth2.jwt.Jwt jwt =
|
||||
mock(org.springframework.security.oauth2.jwt.Jwt.class);
|
||||
ApplicationProperties.Security.OAUTH2 oauth =
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
ApplicationProperties.Security.OAUTH2.Client client =
|
||||
mock(ApplicationProperties.Security.OAUTH2.Client.class);
|
||||
KeycloakProvider keycloakProvider = mock(KeycloakProvider.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"); // Browser request
|
||||
when(request.getHeader("X-Requested-With")).thenReturn(null);
|
||||
|
||||
lenient().when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
lenient().when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
|
||||
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(null);
|
||||
when(client.getKeycloak()).thenReturn(keycloakProvider);
|
||||
when(keycloakProvider.getIssuer()).thenReturn(issuerUrl);
|
||||
when(keycloakProvider.getClientId()).thenReturn(clientId);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
// Verify redirect (not JSON)
|
||||
verify(response).sendRedirect(contains(issuerUrl + "/protocol/openid-connect/logout"));
|
||||
verify(response).sendRedirect(contains("client_id=" + clientId));
|
||||
verify(response).sendRedirect(contains("post_logout_redirect_uri="));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testJwtLogout_ApiRequest_NoOidcEndpoint_ReturnsLocalLogoutUrl() throws IOException {
|
||||
// Test that API requests with no OIDC endpoint return local logout URL as JSON
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
|
||||
jwtAuth =
|
||||
mock(
|
||||
org.springframework.security.oauth2.server.resource.authentication
|
||||
.JwtAuthenticationToken.class);
|
||||
org.springframework.security.oauth2.jwt.Jwt jwt =
|
||||
mock(org.springframework.security.oauth2.jwt.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);
|
||||
|
||||
lenient().when(jwtAuth.getToken()).thenReturn(jwt);
|
||||
lenient().when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
|
||||
|
||||
when(securityProperties.getOauth2()).thenReturn(oauth);
|
||||
when(oauth.getClient()).thenReturn(client);
|
||||
when(client.getEndSessionEndpoint()).thenReturn(null);
|
||||
when(client.getKeycloak()).thenReturn(null); // No Keycloak configured
|
||||
when(oauth.getIssuer()).thenReturn(""); // No issuer
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
|
||||
|
||||
// Verify JSON response with local logout URL
|
||||
verify(response).setStatus(HttpServletResponse.SC_OK);
|
||||
verify(response).setContentType("application/json");
|
||||
|
||||
String jsonResponse = stringWriter.toString();
|
||||
assert jsonResponse.contains("\"logoutUrl\":");
|
||||
assert jsonResponse.contains("/login?logout=true");
|
||||
}
|
||||
}
|
||||
|
||||
57
frontend/package-lock.json
generated
57
frontend/package-lock.json
generated
@ -457,7 +457,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@ -501,7 +500,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@ -582,7 +580,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
|
||||
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.5.0",
|
||||
"@embedpdf/models": "1.5.0"
|
||||
@ -682,7 +679,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
|
||||
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@ -699,7 +695,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
|
||||
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@ -717,7 +712,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
|
||||
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@ -771,7 +765,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
|
||||
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@ -806,7 +799,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
|
||||
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@ -843,7 +835,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
|
||||
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@ -919,7 +910,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
|
||||
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@ -1075,7 +1065,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@ -1119,7 +1108,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@ -2150,7 +2138,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
|
||||
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"clsx": "^2.1.1",
|
||||
@ -2201,7 +2188,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
|
||||
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
@ -2269,7 +2255,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
|
||||
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.5",
|
||||
@ -3202,7 +3187,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
|
||||
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
@ -3321,6 +3305,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
|
||||
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
}
|
||||
@ -4097,7 +4082,6 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@ -4426,7 +4410,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@ -4437,7 +4420,6 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@ -4507,7 +4489,6 @@
|
||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.3",
|
||||
"@typescript-eslint/types": "8.46.3",
|
||||
@ -5221,6 +5202,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
|
||||
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
@ -5230,6 +5212,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
|
||||
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
@ -5240,6 +5223,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/runtime-core": "3.5.24",
|
||||
@ -5252,6 +5236,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
|
||||
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
@ -5278,7 +5263,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -5686,6 +5670,7 @@
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@ -5962,7 +5947,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@ -7010,8 +6994,7 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
|
||||
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@ -7406,7 +7389,6 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@ -7577,7 +7559,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@ -7744,7 +7725,8 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
@ -7809,6 +7791,7 @@
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
|
||||
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
}
|
||||
@ -8899,7 +8882,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@ -9376,6 +9358,7 @@
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
@ -9696,7 +9679,6 @@
|
||||
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.19",
|
||||
"@asamuzakjp/dom-selector": "^6.7.3",
|
||||
@ -10283,7 +10265,8 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
@ -11442,7 +11425,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@ -11722,7 +11704,6 @@
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@ -12105,7 +12086,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -12115,7 +12095,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@ -13627,6 +13606,7 @@
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@ -13835,7 +13815,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -14137,7 +14116,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -14219,7 +14197,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@ -14424,7 +14401,6 @@
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@ -14595,7 +14571,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -14609,7 +14584,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@ -15221,7 +15195,8 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
|
||||
@ -259,7 +259,7 @@ describe('SpringAuthClient', () => {
|
||||
const result = await springAuth.signOut();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/auth/logout',
|
||||
'/logout',
|
||||
null,
|
||||
expect.objectContaining({ withCredentials: true })
|
||||
);
|
||||
|
||||
@ -280,31 +280,39 @@ class SpringAuthClient {
|
||||
|
||||
/**
|
||||
* Sign out user (invalidate session)
|
||||
* Uses Spring Security's logout endpoint which handles OAuth2/SAML logout redirects
|
||||
*/
|
||||
async signOut(): Promise<{ error: AuthError | null }> {
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/auth/logout', null, {
|
||||
// Extract token before making request (needed for OAuth/SAML logout)
|
||||
const token = localStorage.getItem('stirling_jwt');
|
||||
|
||||
const response = await apiClient.post('/logout', null, {
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
'Accept': 'application/json', // Request JSON response for SPA
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
// console.debug('[SpringAuth] signOut: Success');
|
||||
}
|
||||
|
||||
// Clean up local storage
|
||||
// Clean up local storage after successful logout request
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners('SIGNED_OUT', null);
|
||||
// If we got a logout URL (for OIDC/OAuth2 providers), redirect immediately
|
||||
// Don't notify listeners to avoid React re-render before redirect
|
||||
if (response.data?.logoutUrl) {
|
||||
window.location.href = response.data.logoutUrl;
|
||||
return { error: null };
|
||||
}
|
||||
|
||||
this.notifyListeners('SIGNED_OUT', null);
|
||||
window.location.href = '/login?logout=true';
|
||||
return { error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error('[SpringAuth] signOut error:', error);
|
||||
// Still remove token even if backend call fails
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
this.notifyListeners('SIGNED_OUT', null);
|
||||
window.location.href = '/login?logout=true';
|
||||
return {
|
||||
error: { message: getErrorMessage(error, 'Logout failed') },
|
||||
};
|
||||
|
||||
@ -37,17 +37,9 @@ 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 () => {
|
||||
try {
|
||||
await signOut();
|
||||
} finally {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [redirectToLogin, signOut]);
|
||||
await signOut();
|
||||
}, [signOut]);
|
||||
|
||||
const handlePasswordSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
@ -85,6 +85,12 @@ export default defineConfig(({ mode }) => {
|
||||
secure: false,
|
||||
xfwd: true,
|
||||
},
|
||||
'/logout': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
xfwd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
base: process.env.RUN_SUBPATH ? `/${process.env.RUN_SUBPATH}` : './',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user