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:
James Brunton
2026-01-09 18:21:16 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent dd09f7b7cf
commit 18be8f4692
40 changed files with 1877 additions and 135 deletions
@@ -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,
@@ -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;
}
}
@@ -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)
@@ -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();
}
}
@@ -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;
}
}