mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Self-hosted desktop SSO (#5265)
# Description of Changes Support SSO in self-hosted desktop app. --------- Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
parent
dd09f7b7cf
commit
18be8f4692
+14
@@ -18,6 +18,7 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
@@ -46,6 +47,7 @@ import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestResolver;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
|
||||
@@ -82,6 +84,7 @@ public class SecurityConfiguration {
|
||||
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||
|
||||
public SecurityConfiguration(
|
||||
PersistentLoginRepository persistentLoginRepository,
|
||||
@@ -102,6 +105,7 @@ public class SecurityConfiguration {
|
||||
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
|
||||
@Autowired(required = false)
|
||||
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
|
||||
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
|
||||
stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
@@ -120,6 +124,7 @@ public class SecurityConfiguration {
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
|
||||
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
}
|
||||
|
||||
@@ -290,6 +295,15 @@ public class SecurityConfiguration {
|
||||
http.oauth2Login(
|
||||
oauth2 -> {
|
||||
oauth2.loginPage("/login")
|
||||
.authorizationEndpoint(
|
||||
authorizationEndpoint -> {
|
||||
if (clientRegistrationRepository != null) {
|
||||
authorizationEndpoint
|
||||
.authorizationRequestResolver(
|
||||
new TauriAuthorizationRequestResolver(
|
||||
clientRegistrationRepository));
|
||||
}
|
||||
})
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
|
||||
+62
-1
@@ -1,7 +1,11 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
@@ -60,9 +64,66 @@ public class CustomOAuth2AuthenticationFailureHandler
|
||||
"OAuth2 Authentication error: {}",
|
||||
errorCode != null ? errorCode : exception.getMessage(),
|
||||
exception);
|
||||
getRedirectStrategy().sendRedirect(request, response, "/login?errorOAuth=" + errorCode);
|
||||
String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError";
|
||||
clearRedirectCookie(response);
|
||||
boolean tauriState = TauriOAuthUtils.isTauriState(request);
|
||||
String redirectUrl;
|
||||
if (tauriState) {
|
||||
String basePath =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
redirectUrl = basePath;
|
||||
String stateParam = request.getParameter("state");
|
||||
if (stateParam != null && !stateParam.isBlank()) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "state", stateParam);
|
||||
// Extract and pass nonce for CSRF validation
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(stateParam);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
}
|
||||
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue);
|
||||
} else {
|
||||
redirectUrl = buildFailureRedirectUrl(request, errorValue);
|
||||
}
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
return;
|
||||
}
|
||||
log.error("Unhandled authentication exception", exception);
|
||||
super.onAuthenticationFailure(request, response, exception);
|
||||
}
|
||||
|
||||
private String buildFailureRedirectUrl(HttpServletRequest request, String errorValue) {
|
||||
String contextPath = request.getContextPath();
|
||||
String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request);
|
||||
String redirectPath =
|
||||
cookiePath != null ? cookiePath : TauriOAuthUtils.defaultCallbackPath(contextPath);
|
||||
if (TauriOAuthUtils.isTauriState(request)) {
|
||||
redirectPath = appendQueryParam(redirectPath, "tauri", "1");
|
||||
}
|
||||
String resolvedPath =
|
||||
redirectPath.startsWith("/")
|
||||
? TauriOAuthUtils.normalizeContextPath(contextPath) + redirectPath
|
||||
: TauriOAuthUtils.normalizeContextPath(contextPath) + "/" + redirectPath;
|
||||
return appendQueryParam(resolvedPath, "errorOAuth", errorValue);
|
||||
}
|
||||
|
||||
private void clearRedirectCookie(HttpServletResponse response) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "")
|
||||
.path("/")
|
||||
.sameSite("Lax")
|
||||
.maxAge(0)
|
||||
.build();
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||
}
|
||||
|
||||
private String appendQueryParam(String path, String key, String value) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return path;
|
||||
}
|
||||
String separator = path.contains("?") ? "&" : "?";
|
||||
String encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8);
|
||||
String encodedValue = value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
return path + separator + encodedKey + "=" + encodedValue;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-35
@@ -4,8 +4,6 @@ import static stirling.software.proprietary.security.model.AuthenticationType.OA
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -21,7 +19,6 @@ import org.springframework.security.web.authentication.SavedRequestAwareAuthenti
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
@@ -45,9 +42,6 @@ import stirling.software.proprietary.security.service.UserService;
|
||||
public class CustomOAuth2AuthenticationSuccessHandler
|
||||
extends SavedRequestAwareAuthenticationSuccessHandler {
|
||||
|
||||
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
|
||||
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
private final UserService userService;
|
||||
@@ -210,39 +204,28 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
resolveOriginFromReferer(request)
|
||||
.orElseGet(() -> buildOriginFromRequest(request)));
|
||||
clearRedirectCookie(response);
|
||||
return origin + redirectPath + "#access_token=" + jwt;
|
||||
|
||||
// Extract nonce from state for CSRF validation in callback
|
||||
String nonce = TauriOAuthUtils.extractNonceFromRequest(request);
|
||||
String url = origin + redirectPath + "#access_token=" + jwt;
|
||||
if (nonce != null) {
|
||||
url +=
|
||||
"&nonce="
|
||||
+ java.net.URLEncoder.encode(
|
||||
nonce, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
|
||||
return extractRedirectPathFromCookie(request)
|
||||
.filter(path -> path.startsWith("/"))
|
||||
.orElseGet(() -> defaultCallbackPath(contextPath));
|
||||
}
|
||||
|
||||
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return Optional.empty();
|
||||
if (TauriOAuthUtils.isTauriState(request)) {
|
||||
return TauriOAuthUtils.defaultTauriCallbackPath(contextPath);
|
||||
}
|
||||
for (Cookie cookie : cookies) {
|
||||
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
|
||||
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
|
||||
if (!value.isEmpty()) {
|
||||
return Optional.of(value);
|
||||
}
|
||||
}
|
||||
String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request);
|
||||
if (cookiePath != null && cookiePath.startsWith("/")) {
|
||||
return cookiePath;
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String defaultCallbackPath(String contextPath) {
|
||||
if (contextPath == null
|
||||
|| contextPath.isBlank()
|
||||
|| "/".equals(contextPath)
|
||||
|| "\\".equals(contextPath)) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
return contextPath + DEFAULT_CALLBACK_PATH;
|
||||
return TauriOAuthUtils.defaultCallbackPath(contextPath);
|
||||
}
|
||||
|
||||
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
|
||||
@@ -326,7 +309,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
|
||||
private void clearRedirectCookie(HttpServletResponse response) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
|
||||
ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "")
|
||||
.path("/")
|
||||
.sameSite("Lax")
|
||||
.maxAge(0)
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
|
||||
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
public class TauriAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
|
||||
|
||||
private static final String TAURI_STATE_PREFIX = "tauri:";
|
||||
|
||||
private final OAuth2AuthorizationRequestResolver delegate;
|
||||
|
||||
public TauriAuthorizationRequestResolver(
|
||||
ClientRegistrationRepository clientRegistrationRepository) {
|
||||
this.delegate =
|
||||
new DefaultOAuth2AuthorizationRequestResolver(
|
||||
clientRegistrationRepository, "/oauth2/authorization");
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
|
||||
return customize(request, delegate.resolve(request));
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AuthorizationRequest resolve(
|
||||
HttpServletRequest request, String clientRegistrationId) {
|
||||
return customize(request, delegate.resolve(request, clientRegistrationId));
|
||||
}
|
||||
|
||||
private OAuth2AuthorizationRequest customize(
|
||||
HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) {
|
||||
if (authorizationRequest == null) {
|
||||
return null;
|
||||
}
|
||||
String tauriParam = request.getParameter("tauri");
|
||||
if (!"1".equals(tauriParam)) {
|
||||
return authorizationRequest;
|
||||
}
|
||||
|
||||
String state = authorizationRequest.getState();
|
||||
if (state == null || state.startsWith(TAURI_STATE_PREFIX)) {
|
||||
return authorizationRequest;
|
||||
}
|
||||
|
||||
// Extract nonce from request for CSRF protection
|
||||
String nonce = request.getParameter("nonce");
|
||||
String customState = TAURI_STATE_PREFIX + state;
|
||||
if (nonce != null && !nonce.isBlank()) {
|
||||
customState = customState + ":" + nonce;
|
||||
}
|
||||
|
||||
return OAuth2AuthorizationRequest.from(authorizationRequest).state(customState).build();
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Utility class for Tauri desktop OAuth flow handling. Centralizes common logic for OAuth state
|
||||
* management, nonce validation, and callback path construction.
|
||||
*/
|
||||
public final class TauriOAuthUtils {
|
||||
|
||||
public static final String TAURI_STATE_PREFIX = "tauri:";
|
||||
public static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||
public static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
|
||||
public static final String TAURI_CALLBACK_SUFFIX = "/tauri";
|
||||
|
||||
private TauriOAuthUtils() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts nonce from OAuth state parameter for CSRF validation. State format:
|
||||
* tauri:<original-state>:<nonce>
|
||||
*
|
||||
* @param state The state parameter value
|
||||
* @return The nonce if present, null otherwise
|
||||
*/
|
||||
public static String extractNonceFromState(String state) {
|
||||
if (state == null || !state.startsWith(TAURI_STATE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
// Split by ':' and get the last part (nonce)
|
||||
String[] parts = state.split(":");
|
||||
if (parts.length >= 3) {
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts nonce from request's state parameter.
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return The nonce if present, null otherwise
|
||||
*/
|
||||
public static String extractNonceFromRequest(HttpServletRequest request) {
|
||||
String state = request.getParameter("state");
|
||||
return extractNonceFromState(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request has a Tauri state parameter (desktop OAuth flow).
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return true if this is a Tauri desktop OAuth flow, false otherwise
|
||||
*/
|
||||
public static boolean isTauriState(HttpServletRequest request) {
|
||||
String state = request.getParameter("state");
|
||||
return state != null && state.startsWith(TAURI_STATE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the default callback path for the given context path.
|
||||
*
|
||||
* @param contextPath The application context path
|
||||
* @return The full callback path
|
||||
*/
|
||||
public static String defaultCallbackPath(String contextPath) {
|
||||
if (contextPath == null
|
||||
|| contextPath.isBlank()
|
||||
|| "/".equals(contextPath)
|
||||
|| "\\".equals(contextPath)) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
return contextPath + DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Tauri-specific callback path (includes /tauri suffix).
|
||||
*
|
||||
* @param contextPath The application context path
|
||||
* @return The full Tauri callback path
|
||||
*/
|
||||
public static String defaultTauriCallbackPath(String contextPath) {
|
||||
return defaultCallbackPath(contextPath) + TAURI_CALLBACK_SUFFIX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes context path by removing trailing slashes and handling empty/root paths.
|
||||
*
|
||||
* @param contextPath The context path to normalize
|
||||
* @return Normalized context path (empty string for root)
|
||||
*/
|
||||
public static String normalizeContextPath(String contextPath) {
|
||||
if (contextPath == null || contextPath.isBlank() || "/".equals(contextPath)) {
|
||||
return "";
|
||||
}
|
||||
return contextPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the SPA redirect cookie value from the request.
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return The redirect path from cookie, or null if not found
|
||||
*/
|
||||
public static String extractRedirectPathFromCookie(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
for (Cookie cookie : cookies) {
|
||||
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
|
||||
String value =
|
||||
java.net.URLDecoder.decode(
|
||||
cookie.getValue(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
return value.trim().isEmpty() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
|
||||
class CustomOAuth2AuthenticationFailureHandlerTest {
|
||||
|
||||
@Test
|
||||
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
|
||||
CustomOAuth2AuthenticationFailureHandler handler =
|
||||
new CustomOAuth2AuthenticationFailureHandler();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
request.setParameter("state", "tauri:abc");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
|
||||
|
||||
assertEquals(
|
||||
"/auth/callback/tauri?state=tauri%3Aabc&errorOAuth=access_denied",
|
||||
response.getRedirectedUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void redirectsToDefaultCallbackWithoutTauriState() throws Exception {
|
||||
CustomOAuth2AuthenticationFailureHandler handler =
|
||||
new CustomOAuth2AuthenticationFailureHandler();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
|
||||
|
||||
assertEquals("/auth/callback?errorOAuth=access_denied", response.getRedirectedUrl());
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CustomOAuth2AuthenticationSuccessHandlerTest {
|
||||
|
||||
@Test
|
||||
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
|
||||
LoginAttemptService loginAttemptService = mock(LoginAttemptService.class);
|
||||
UserService userService = mock(UserService.class);
|
||||
JwtServiceInterface jwtService = mock(JwtServiceInterface.class);
|
||||
UserLicenseSettingsService licenseSettingsService = mock(UserLicenseSettingsService.class);
|
||||
|
||||
ApplicationProperties.Security.OAUTH2 oauth2Props =
|
||||
new ApplicationProperties.Security.OAUTH2();
|
||||
oauth2Props.setAutoCreateUser(true);
|
||||
oauth2Props.setBlockRegistration(false);
|
||||
|
||||
CustomOAuth2AuthenticationSuccessHandler handler =
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
oauth2Props,
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService);
|
||||
|
||||
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
|
||||
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
|
||||
when(userService.isUserDisabled("user")).thenReturn(false);
|
||||
when(jwtService.isJwtEnabled()).thenReturn(true);
|
||||
when(jwtService.generateToken(
|
||||
org.mockito.Mockito.any(
|
||||
org.springframework.security.core.Authentication.class),
|
||||
org.mockito.Mockito.anyMap()))
|
||||
.thenReturn("jwt");
|
||||
|
||||
Map<String, Object> attributes = Map.of("sub", "provider-sub", "name", "user");
|
||||
DefaultOAuth2User oauthUser =
|
||||
new DefaultOAuth2User(
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER")), attributes, "name");
|
||||
OAuth2AuthenticationToken authentication =
|
||||
new OAuth2AuthenticationToken(oauthUser, oauthUser.getAuthorities(), "google");
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
request.setScheme("http");
|
||||
request.setServerName("localhost");
|
||||
request.setServerPort(8080);
|
||||
request.setParameter("state", "tauri:abc");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication);
|
||||
|
||||
assertEquals(
|
||||
"http://localhost:8080/auth/callback/tauri#access_token=jwt",
|
||||
response.getRedirectedUrl());
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
|
||||
class TauriAuthorizationRequestResolverTest {
|
||||
|
||||
private TauriAuthorizationRequestResolver buildResolver() {
|
||||
ClientRegistration registration =
|
||||
ClientRegistration.withRegistrationId("google")
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.authorizationUri("https://accounts.example.com/o/oauth2/auth")
|
||||
.tokenUri("https://accounts.example.com/o/oauth2/token")
|
||||
.redirectUri("http://localhost:8080/login/oauth2/code/google")
|
||||
.userInfoUri("https://accounts.example.com/userinfo")
|
||||
.userNameAttributeName("sub")
|
||||
.clientName("Google")
|
||||
.scope("email")
|
||||
.build();
|
||||
|
||||
return new TauriAuthorizationRequestResolver(
|
||||
new InMemoryClientRegistrationRepository(registration));
|
||||
}
|
||||
|
||||
private MockHttpServletRequest buildRequest(boolean tauri) {
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest("GET", "/oauth2/authorization/google");
|
||||
request.setServletPath("/oauth2/authorization/google");
|
||||
if (tauri) {
|
||||
request.setParameter("tauri", "1");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_prefixesStateWhenTauriParamPresent() {
|
||||
TauriAuthorizationRequestResolver resolver = buildResolver();
|
||||
OAuth2AuthorizationRequest authRequest = resolver.resolve(buildRequest(true));
|
||||
assertNotNull(authRequest);
|
||||
assertNotNull(authRequest.getState());
|
||||
assertTrue(authRequest.getState().startsWith("tauri:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_doesNotPrefixStateWithoutTauriParam() {
|
||||
TauriAuthorizationRequestResolver resolver = buildResolver();
|
||||
OAuth2AuthorizationRequest authRequest = resolver.resolve(buildRequest(false));
|
||||
assertNotNull(authRequest);
|
||||
assertNotNull(authRequest.getState());
|
||||
assertFalse(authRequest.getState().startsWith("tauri:"));
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
class TauriOAuthUtilsTest {
|
||||
|
||||
@Test
|
||||
void extractNonceFromState_validState() {
|
||||
String state = "tauri:original-state-12345:test-nonce-uuid";
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(state);
|
||||
assertEquals("test-nonce-uuid", nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractNonceFromState_stateWithColonInNonce() {
|
||||
String state = "tauri:original:complex:nonce-with-colon";
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(state);
|
||||
assertEquals("nonce-with-colon", nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractNonceFromState_noNonce() {
|
||||
String state = "tauri:original-state";
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(state);
|
||||
assertNull(nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractNonceFromState_notTauriState() {
|
||||
String state = "regular-state:with-colons";
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(state);
|
||||
assertNull(nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractNonceFromState_nullState() {
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(null);
|
||||
assertNull(nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractNonceFromRequest_validRequest() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setParameter("state", "tauri:abc:nonce-123");
|
||||
|
||||
String nonce = TauriOAuthUtils.extractNonceFromRequest(request);
|
||||
assertEquals("nonce-123", nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTauriState_validTauriState() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setParameter("state", "tauri:original-state");
|
||||
|
||||
assertTrue(TauriOAuthUtils.isTauriState(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTauriState_notTauriState() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setParameter("state", "regular-state");
|
||||
|
||||
assertFalse(TauriOAuthUtils.isTauriState(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTauriState_noState() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
|
||||
assertFalse(TauriOAuthUtils.isTauriState(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultCallbackPath_rootContext() {
|
||||
assertEquals("/auth/callback", TauriOAuthUtils.defaultCallbackPath("/"));
|
||||
assertEquals("/auth/callback", TauriOAuthUtils.defaultCallbackPath(""));
|
||||
assertEquals("/auth/callback", TauriOAuthUtils.defaultCallbackPath(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultCallbackPath_withContext() {
|
||||
assertEquals("/myapp/auth/callback", TauriOAuthUtils.defaultCallbackPath("/myapp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultTauriCallbackPath_rootContext() {
|
||||
assertEquals("/auth/callback/tauri", TauriOAuthUtils.defaultTauriCallbackPath("/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultTauriCallbackPath_withContext() {
|
||||
assertEquals(
|
||||
"/myapp/auth/callback/tauri", TauriOAuthUtils.defaultTauriCallbackPath("/myapp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeContextPath_rootPaths() {
|
||||
assertEquals("", TauriOAuthUtils.normalizeContextPath("/"));
|
||||
assertEquals("", TauriOAuthUtils.normalizeContextPath(""));
|
||||
assertEquals("", TauriOAuthUtils.normalizeContextPath(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeContextPath_withPath() {
|
||||
assertEquals("/myapp", TauriOAuthUtils.normalizeContextPath("/myapp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractRedirectPathFromCookie_noCookies() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
assertNull(TauriOAuthUtils.extractRedirectPathFromCookie(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractRedirectPathFromCookie_withCookie() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setCookies(
|
||||
new jakarta.servlet.http.Cookie(
|
||||
TauriOAuthUtils.SPA_REDIRECT_COOKIE, "/auth/callback"));
|
||||
|
||||
assertEquals("/auth/callback", TauriOAuthUtils.extractRedirectPathFromCookie(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractRedirectPathFromCookie_emptyCookie() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setCookies(
|
||||
new jakarta.servlet.http.Cookie(TauriOAuthUtils.SPA_REDIRECT_COOKIE, ""));
|
||||
|
||||
assertNull(TauriOAuthUtils.extractRedirectPathFromCookie(request));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user