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
@@ -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());
}
}
@@ -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());
}
}
@@ -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:"));
}
}
@@ -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));
}
}