diff --git a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java index 9cb4a786a..e913aefaf 100644 --- a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java +++ b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -141,10 +141,10 @@ public class SPDFApplication { String backendUrl = appConfig.getBackendUrl(); String contextPath = appConfig.getContextPath(); String serverPort = appConfig.getServerPort(); - baseUrlStatic = backendUrl; + baseUrlStatic = normalizeBackendUrl(backendUrl, serverPort); contextPathStatic = contextPath; serverPortStatic = serverPort; - String url = backendUrl + ":" + getStaticPort() + contextPath; + String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic); // Log Tauri mode information if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) { @@ -210,7 +210,7 @@ public class SPDFApplication { private static void printStartupLogs() { log.info("Stirling-PDF Started."); - String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic; + String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic); log.info("Navigate to {}", url); } @@ -258,4 +258,79 @@ public class SPDFApplication { public static String getStaticContextPath() { return contextPathStatic; } + + private static String buildFullUrl(String backendUrl, String port, String contextPath) { + String normalizedBase = normalizeBackendUrl(backendUrl, port); + + String normalizedContextPath = + (contextPath == null || contextPath.isBlank() || "/".equals(contextPath)) + ? "/" + : (contextPath.startsWith("/") ? contextPath : "/" + contextPath); + + return normalizedBase + normalizedContextPath; + } + + private static String normalizeBackendUrl(String backendUrl, String port) { + String trimmedBase = + (backendUrl == null || backendUrl.isBlank()) + ? "http://localhost" + : backendUrl.trim().replaceAll("/+$", ""); + boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*"); + String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase; + Integer parsedPort = parsePort(port); + + try { + java.net.URI uri = new java.net.URI(baseForParsing); + String scheme = uri.getScheme() == null ? "http" : uri.getScheme(); + String host = uri.getHost(); + if (host == null) { + return appendPortFallback(trimmedBase, parsedPort); + } + + boolean defaultHttp = + "http".equalsIgnoreCase(scheme) && Integer.valueOf(80).equals(parsedPort); + boolean defaultHttps = + "https".equalsIgnoreCase(scheme) && Integer.valueOf(443).equals(parsedPort); + + int effectivePort = uri.getPort(); + if (effectivePort == -1 && parsedPort != null && !defaultHttp && !defaultHttps) { + effectivePort = parsedPort; + } + + java.net.URI rebuilt = + new java.net.URI( + scheme, + uri.getUserInfo(), + host, + effectivePort, + uri.getPath(), + uri.getQuery(), + uri.getFragment()); + return rebuilt.toString(); + } catch (java.net.URISyntaxException e) { + return appendPortFallback(trimmedBase, parsedPort); + } + } + + private static Integer parsePort(String port) { + if (port == null || port.isBlank()) { + return null; + } + try { + int parsed = Integer.parseInt(port); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException e) { + return null; + } + } + + private static String appendPortFallback(String trimmedBase, Integer port) { + if (port == null) { + return trimmedBase; + } + if (trimmedBase.matches(".+:\\d+$")) { + return trimmedBase; + } + return trimmedBase + ":" + port; + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 1b657012f..027b466c0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -15,84 +15,106 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.util.HtmlUtils; +import org.springframework.web.util.JavaScriptUtils; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; - import stirling.software.common.configuration.InstallationPathConfig; -@Slf4j @Controller public class ReactRoutingController { + private static final org.slf4j.Logger log = + org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class); + @Value("${server.servlet.context-path:/}") private String contextPath; private String cachedIndexHtml; + private String cachedCallbackHtml; private boolean indexHtmlExists = false; private boolean useExternalIndexHtml = false; + private boolean loggedMissingIndex = false; @PostConstruct public void init() { log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath()); + // Always initialize callback HTML (used for OAuth desktop flow) + this.cachedCallbackHtml = buildCallbackHtml(); + // Check for external index.html first (customFiles/static/) Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html"); log.debug("Checking for custom index.html at: {}", externalIndexPath); if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) { log.info("Using custom index.html from: {}", externalIndexPath); - try { - this.cachedIndexHtml = processIndexHtml(); - this.indexHtmlExists = true; - this.useExternalIndexHtml = true; - return; - } catch (IOException e) { - log.warn("Failed to load custom index.html, falling back to classpath", e); - } + this.cachedIndexHtml = processIndexHtml(); + this.indexHtmlExists = true; + this.useExternalIndexHtml = true; + return; } // Fall back to classpath index.html ClassPathResource resource = new ClassPathResource("static/index.html"); if (resource.exists()) { - try { - this.cachedIndexHtml = processIndexHtml(); - this.indexHtmlExists = true; - this.useExternalIndexHtml = false; - } catch (IOException e) { - // Failed to cache, will process on each request - log.warn("Failed to cache index.html", e); - this.indexHtmlExists = false; + this.cachedIndexHtml = processIndexHtml(); + this.indexHtmlExists = true; + this.useExternalIndexHtml = false; + return; + } + + // Neither external nor classpath index.html exists - cache fallback once + this.cachedIndexHtml = buildFallbackHtml(); + this.indexHtmlExists = true; + this.useExternalIndexHtml = false; + this.loggedMissingIndex = true; + log.warn( + "index.html not found in classpath or custom path; using lightweight fallback page"); + } + + private String processIndexHtml() { + try { + Resource resource = getIndexHtmlResource(); + + if (!resource.exists()) { + if (!loggedMissingIndex) { + log.warn("index.html not found, using lightweight fallback page"); + loggedMissingIndex = true; + } + return buildFallbackHtml(); } + + try (InputStream inputStream = resource.getInputStream()) { + String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + + // Replace %BASE_URL% with the actual context path for base href + String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/"; + html = html.replace("%BASE_URL%", baseUrl); + // Also rewrite any existing tag (Vite may have baked one in) + html = + html.replaceFirst( + "", + ""); + + // Inject context path as a global variable for API calls + String contextPathScript = + ""; + html = html.replace("", contextPathScript + ""); + + return html; + } + } catch (Exception ex) { + if (!loggedMissingIndex) { + log.warn("index.html not found, using lightweight fallback page", ex); + loggedMissingIndex = true; + } + return buildFallbackHtml(); } } - private String processIndexHtml() throws IOException { - Resource resource = getIndexHtmlResource(); - - try (InputStream inputStream = resource.getInputStream()) { - String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - - // Replace %BASE_URL% with the actual context path for base href - String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/"; - html = html.replace("%BASE_URL%", baseUrl); - // Also rewrite any existing tag (Vite may have baked one in) - html = - html.replaceFirst( - "", - ""); - - // Inject context path as a global variable for API calls - String contextPathScript = - ""; - html = html.replace("", contextPathScript + ""); - - return html; - } - } - - private Resource getIndexHtmlResource() throws IOException { + private Resource getIndexHtmlResource() { // Check external location first Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html"); if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) { @@ -106,12 +128,28 @@ public class ReactRoutingController { @GetMapping( value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE) - public ResponseEntity serveIndexHtml(HttpServletRequest request) throws IOException { - if (indexHtmlExists && cachedIndexHtml != null) { - return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml); + public ResponseEntity serveIndexHtml(HttpServletRequest request) { + try { + if (indexHtmlExists && cachedIndexHtml != null) { + return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml); + } + // Fallback: process on each request (dev mode or cache failed) + return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml()); + } catch (Exception ex) { + log.error("Failed to serve index.html, returning fallback", ex); + return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(buildFallbackHtml()); } - // Fallback: process on each request (dev mode or cache failed) - return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml()); + } + + @GetMapping(value = "/auth/callback", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity serveAuthCallback(HttpServletRequest request) { + return serveIndexHtml(request); + } + + @GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity serveTauriAuthCallback(HttpServletRequest request) { + // cachedCallbackHtml is always initialized in @PostConstruct + return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml); } @GetMapping( @@ -126,4 +164,309 @@ public class ReactRoutingController { throws IOException { return serveIndexHtml(request); } + + private String buildFallbackHtml() { + String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/"; + + // Escape for HTML attribute context + String escapedBaseUrlHtml = HtmlUtils.htmlEscape(baseUrl); + + // Escape for JavaScript string context + String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl); + + String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')"; + return """ + + + + + + Stirling PDF + + + +

Stirling PDF is running.

+ + + """ + .formatted(escapedBaseUrlHtml, escapedBaseUrlJs, serverUrl); + } + + private String buildCallbackHtml() { + String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/"; + + // Escape for HTML attribute context + String escapedBaseUrlHtml = HtmlUtils.htmlEscape(baseUrl); + + // Escape for JavaScript string context + String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl); + + String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')"; + return """ + + + + + + Authentication Complete + + + + +
+
+

Authentication complete

+

You can close this window and return to Stirling PDF.

+
+
+ + + """ + .formatted(escapedBaseUrlHtml, serverUrl); + } } diff --git a/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java b/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java index a20ccebb4..bb3bd30f2 100644 --- a/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java @@ -55,7 +55,7 @@ public class SPDFApplicationTest { sPDFApplication.init(); - assertEquals("http://localhost", SPDFApplication.getStaticBaseUrl()); + assertEquals("http://localhost:8080", SPDFApplication.getStaticBaseUrl()); assertEquals("/app", SPDFApplication.getStaticContextPath()); assertEquals("8080", SPDFApplication.getStaticPort()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index fa936211d..b84f651d1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -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, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java index 2f85bd566..784a9f0a2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java @@ -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; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 793c6b62f..36975dc84 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -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 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 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) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/TauriAuthorizationRequestResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/TauriAuthorizationRequestResolver.java new file mode 100644 index 000000000..8af7bbeca --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/TauriAuthorizationRequestResolver.java @@ -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(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/TauriOAuthUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/TauriOAuthUtils.java new file mode 100644 index 000000000..4d801f90c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/TauriOAuthUtils.java @@ -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:: + * + * @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; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandlerTest.java new file mode 100644 index 000000000..349940bd5 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandlerTest.java @@ -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()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java new file mode 100644 index 000000000..376d0b4ed --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java @@ -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 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()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/TauriAuthorizationRequestResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/TauriAuthorizationRequestResolverTest.java new file mode 100644 index 000000000..33d436c60 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/TauriAuthorizationRequestResolverTest.java @@ -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:")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/TauriOAuthUtilsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/TauriOAuthUtilsTest.java new file mode 100644 index 000000000..b6264bb1b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/TauriOAuthUtilsTest.java @@ -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)); + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7eba18203..b3a2fda01 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -50,6 +50,7 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-fs": "^2.4.0", "@tauri-apps/plugin-http": "^2.5.4", + "@tauri-apps/plugin-shell": "^2.3.3", "autoprefixer": "^10.4.21", "axios": "^1.12.2", "globals": "^16.4.0", @@ -4086,6 +4087,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.3.tgz", + "integrity": "sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 788f8eb81..5cd149d22 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-fs": "^2.4.0", "@tauri-apps/plugin-http": "^2.5.4", + "@tauri-apps/plugin-shell": "^2.3.3", "autoprefixer": "^10.4.21", "axios": "^1.12.2", "globals": "^16.4.0", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 4080330a8..c955750bb 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -531,6 +531,8 @@ accountSettings = "Account Settings" adminSettings = "Admin Settings - View and Add Users" userControlSettings = "User Control Settings" changeUsername = "Change Username" +changeUsernameDescription = "Update your username. You will be logged out after updating." +newUsernamePlaceholder = "Enter your new username" newUsername = "New Username" password = "Confirmation Password" oldPassword = "Old password" @@ -6233,6 +6235,7 @@ connectingTo = "Connecting to:" submit = "Login" signInWith = "Sign in with" oauthPending = "Opening browser for authentication..." +sso = "Single Sign-On" orContinueWith = "Or continue with email" serverRequirement = "Note: The server must have login enabled." showInstructions = "How to enable?" diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs index 3e75b452e..62a2c832f 100644 --- a/frontend/src-tauri/src/commands/auth.rs +++ b/frontend/src-tauri/src/commands/auth.rs @@ -85,7 +85,14 @@ pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> { // Delete the token - ignore error if it doesn't exist match entry.delete_credential() { Ok(_) | Err(keyring::Error::NoEntry) => Ok(()), - Err(e) => Err(format!("Failed to clear token: {}", e)), + Err(e) => { + log::warn!("Failed to delete keyring credential: {}. Attempting overwrite with empty token.", e); + // As a fallback, overwrite with an empty token so a stale value cannot be reused + match entry.set_password("") { + Ok(_) => Ok(()), + Err(e2) => Err(format!("Failed to clear token (delete + overwrite failed): {}", e2)), + } + }, } } diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index 6b82fd820..8fcd9c64d 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -8,6 +8,7 @@ import { useBackendInitializer } from '@app/hooks/useBackendInitializer'; import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig'; import { connectionModeService } from '@desktop/services/connectionModeService'; import { tauriBackendService } from '@app/services/tauriBackendService'; +import { authService } from '@app/services/authService'; /** * Desktop application providers @@ -18,12 +19,26 @@ import { tauriBackendService } from '@app/services/tauriBackendService'; export function AppProviders({ children }: { children: ReactNode }) { const { isFirstLaunch, setupComplete } = useFirstLaunchCheck(); const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null); + const [authChecked, setAuthChecked] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); // Load connection mode on mount useEffect(() => { void connectionModeService.getCurrentMode().then(setConnectionMode); }, []); + useEffect(() => { + if (!isFirstLaunch && setupComplete) { + authService.isAuthenticated() + .then(setIsAuthenticated) + .catch(() => setIsAuthenticated(false)) + .finally(() => setAuthChecked(true)); + } else if (isFirstLaunch && !setupComplete) { + setAuthChecked(true); + setIsAuthenticated(false); + } + }, [isFirstLaunch, setupComplete]); + // Initialize backend health monitoring for self-hosted mode useEffect(() => { if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') { @@ -59,6 +74,29 @@ export function AppProviders({ children }: { children: ReactNode }) { ); } + // Show setup wizard when not authenticated (desktop login flow). + if (authChecked && !isAuthenticated) { + return ( + + { + window.location.reload(); + }} + /> + + ); + } + // Normal app flow return ( Promise; onError: (error: string) => void; isDisabled: boolean; serverUrl: string; - providers: OAuthProvider[]; + providers: DesktopSSOProvider[]; + mode?: 'saas' | 'selfHosted'; } export const DesktopOAuthButtons: React.FC = ({ @@ -21,11 +30,12 @@ export const DesktopOAuthButtons: React.FC = ({ isDisabled, serverUrl, providers, + mode = 'saas', }) => { const { t } = useTranslation(); const [oauthLoading, setOauthLoading] = useState(false); - const handleOAuthLogin = async (provider: OAuthProvider) => { + const handleOAuthLogin = async (provider: DesktopSSOProvider) => { // Prevent concurrent OAuth attempts if (oauthLoading || isDisabled) { return; @@ -48,7 +58,12 @@ export const DesktopOAuthButtons: React.FC = ({ errorPlaceholder: true, // {error} will be replaced by Rust }); - const userInfo = await authService.loginWithOAuth(provider, serverUrl, successHtml, errorHtml); + const normalizedServer = serverUrl.replace(/\/+$/, ''); + const usingSupabaseFlow = + mode === 'saas' || normalizedServer === STIRLING_SAAS_URL.replace(/\/+$/, ''); + const userInfo = usingSupabaseFlow + ? await authService.loginWithOAuth(provider.id, serverUrl, successHtml, errorHtml) + : await authService.loginWithSelfHostedOAuth(provider.path || provider.id, serverUrl); // Call the onOAuthSuccess callback to complete setup await onOAuthSuccess(userInfo); @@ -64,7 +79,7 @@ export const DesktopOAuthButtons: React.FC = ({ } }; - const providerConfig: Record = { + const providerConfig: Record = { google: { label: 'Google', file: 'google.svg' }, github: { label: 'GitHub', file: 'github.svg' }, keycloak: { label: 'Keycloak', file: 'keycloak.svg' }, @@ -72,6 +87,9 @@ export const DesktopOAuthButtons: React.FC = ({ apple: { label: 'Apple', file: 'apple.svg' }, oidc: { label: 'OpenID', file: 'oidc.svg' }, }; + const isKnownProvider = (id: OAuthProviderId): id is KnownProviderId => + (id as KnownProviderId) in providerConfig; + const GENERIC_PROVIDER_ICON = 'oidc.svg'; if (providers.length === 0) { return null; @@ -80,23 +98,31 @@ export const DesktopOAuthButtons: React.FC = ({ return (
{providers - .filter((providerId) => providerId in providerConfig) - .map((providerId) => { - const provider = providerConfig[providerId]; + .filter((providerConfigEntry) => providerConfigEntry && providerConfigEntry.id) + .map((providerEntry) => { + const iconConfig = isKnownProvider(providerEntry.id) + ? providerConfig[providerEntry.id] + : undefined; + const label = + providerEntry.label || + iconConfig?.label || + (providerEntry.id + ? providerEntry.id.charAt(0).toUpperCase() + providerEntry.id.slice(1) + : t('setup.login.sso', 'Single Sign-On')); return ( ); })} diff --git a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx index 6acd28858..644e5fa98 100644 --- a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx +++ b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx @@ -66,7 +66,11 @@ export const SaaSLoginScreen: React.FC = ({ onError={handleOAuthError} isDisabled={loading} serverUrl={serverUrl} - providers={['google', 'github']} + mode="saas" + providers={[ + { id: 'google' }, + { id: 'github' }, + ]} /> Promise; onOAuthSuccess: (userInfo: UserInfo) => Promise; loading: boolean; @@ -74,7 +75,8 @@ export const SelfHostedLoginScreen: React.FC = ({ onError={handleOAuthError} isDisabled={loading} serverUrl={serverUrl} - providers={enabledOAuthProviders as OAuthProvider[]} + mode="selfHosted" + providers={enabledOAuthProviders} /> = ({ onSelect, load } // Fetch OAuth providers and check if login is enabled - let enabledProviders: string[] = []; + const enabledProviders: SSOProviderConfig[] = []; try { const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`); @@ -76,9 +76,19 @@ export const ServerSelection: React.FC = ({ onSelect, load // Extract provider IDs from authorization URLs // Example: "/oauth2/authorization/google" → "google" - enabledProviders = Object.keys(data.providerList || {}) - .map(key => key.split('/').pop()) - .filter((id): id is string => id !== undefined); + const providerEntries = Object.entries(data.providerList || {}); + providerEntries.forEach(([path, label]) => { + const id = path.split('/').pop(); + if (!id) { + return; + } + + enabledProviders.push({ + id, + path, + label: typeof label === 'string' ? label : undefined, + }); + }); console.log('[ServerSelection] Detected OAuth providers:', enabledProviders); } catch (err) { diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index 9fbd0e6cc..b6e18a6c7 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -155,6 +155,28 @@ export const SetupWizard: React.FC = ({ onComplete }) => { const params = new URLSearchParams(hash); const accessToken = params.get('access_token'); const type = params.get('type') || parsed.searchParams.get('type'); + const accessTokenFromHash = params.get('access_token'); + const accessTokenFromQuery = parsed.searchParams.get('access_token'); + const serverFromQuery = parsed.searchParams.get('server'); + + // Handle self-hosted SSO deep link + if (type === 'sso' || type === 'sso-selfhosted') { + const token = accessTokenFromHash || accessTokenFromQuery; + const serverUrl = serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL; + if (!token || !serverUrl) { + console.error('[SetupWizard] Deep link missing token or server for SSO completion'); + return; + } + + setLoading(true); + setError(null); + + await authService.completeSelfHostedSession(serverUrl, token); + await connectionModeService.switchToSelfHosted({ url: serverUrl }); + await tauriBackendService.initializeExternalBackend(); + onComplete(); + return; + } if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) { return; diff --git a/frontend/src/desktop/extensions/accountLogout.ts b/frontend/src/desktop/extensions/accountLogout.ts new file mode 100644 index 000000000..59099ac1b --- /dev/null +++ b/frontend/src/desktop/extensions/accountLogout.ts @@ -0,0 +1,31 @@ +import { connectionModeService } from '@app/services/connectionModeService'; +import { STIRLING_SAAS_URL } from '@app/constants/connection'; + +type SignOutFn = () => Promise; + +interface AccountLogoutDeps { + signOut: SignOutFn; + redirectToLogin: () => void; +} + +/** + * Desktop-specific logout: mirrors Connection Settings flow to avoid stale state. + */ +export function useAccountLogout() { + return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise => { + try { + await signOut(); + + await connectionModeService.switchToSaaS(STIRLING_SAAS_URL); + await connectionModeService.resetSetupCompletion().catch(() => {}); + + window.history.replaceState({}, '', '/'); + window.location.reload(); + return; + } catch (err) { + console.warn('[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect', err); + } + + redirectToLogin(); + }; +} diff --git a/frontend/src/desktop/extensions/authCallback.ts b/frontend/src/desktop/extensions/authCallback.ts new file mode 100644 index 000000000..6ffcb0e27 --- /dev/null +++ b/frontend/src/desktop/extensions/authCallback.ts @@ -0,0 +1,25 @@ +/** + * Desktop-specific OAuth callback handling for self-hosted SSO flows. + */ +export async function handleAuthCallbackSuccess(token: string): Promise { + // Notify desktop popup listeners (self-hosted SSO flow) + const isDesktopPopup = typeof window !== 'undefined' && window.opener && window.name === 'stirling-desktop-sso'; + if (isDesktopPopup) { + try { + window.opener.postMessage({ type: 'stirling-desktop-sso', token }, '*'); + } catch (postError) { + console.error('[AuthCallback] Failed to notify desktop window:', postError); + } + + // Give the message a moment to flush before attempting to close + setTimeout(() => { + try { + window.close(); + } catch { + // ignore close errors + } + }, 150); + } + + // No-op beyond popup notification; deep link flow handles desktop completion. +} diff --git a/frontend/src/desktop/extensions/authSessionCleanup.ts b/frontend/src/desktop/extensions/authSessionCleanup.ts new file mode 100644 index 000000000..a4d8eb6ea --- /dev/null +++ b/frontend/src/desktop/extensions/authSessionCleanup.ts @@ -0,0 +1,20 @@ +import { authService } from '@app/services/authService'; + +/** + * Desktop-specific auth cleanup hooks. + */ +export async function clearPlatformAuthAfterSignOut(): Promise { + try { + await authService.localClearAuth(); + } catch (err) { + console.warn('[AuthCleanup] Failed to clear desktop auth data after sign out', err); + } +} + +export async function clearPlatformAuthOnLoginInit(): Promise { + try { + await authService.localClearAuth(); + } catch (err) { + console.warn('[AuthCleanup] Failed to clear desktop auth data on login init', err); + } +} diff --git a/frontend/src/desktop/extensions/oauthNavigation.ts b/frontend/src/desktop/extensions/oauthNavigation.ts new file mode 100644 index 000000000..d6444ba03 --- /dev/null +++ b/frontend/src/desktop/extensions/oauthNavigation.ts @@ -0,0 +1,23 @@ +import { authService } from '@app/services/authService'; +import { connectionModeService } from '@app/services/connectionModeService'; + +/** + * Desktop-specific OAuth navigation: prefer popup/system browser, avoid hijacking main webview. + */ +export async function startOAuthNavigation(redirectUrl: string): Promise { + try { + const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null); + const serverUrl = currentConfig?.server_config?.url; + if (!serverUrl) { + return false; + } + + const providerUrl = new URL(redirectUrl, serverUrl); + const providerPath = `${providerUrl.pathname}${providerUrl.search}`; + await authService.loginWithSelfHostedOAuth(providerPath, serverUrl); + return true; + } catch (error) { + console.warn('[Desktop OAuthNavigation] Failed to start OAuth flow', error); + return false; + } +} diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index 710518f36..6f06dff69 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -1,4 +1,8 @@ -import { invoke } from '@tauri-apps/api/core'; +import { invoke, isTauri } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { open as shellOpen } from '@tauri-apps/plugin-shell'; +import { connectionModeService } from '@app/services/connectionModeService'; +import { tauriBackendService } from '@app/services/tauriBackendService'; import axios from 'axios'; import { DESKTOP_DEEP_LINK_CALLBACK, STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection'; @@ -108,8 +112,34 @@ export class AuthService { this.cachedToken = null; console.log('[Desktop AuthService] Cache invalidated'); - await invoke('clear_auth_token'); - localStorage.removeItem('stirling_jwt'); + // Best effort: clear Tauri keyring + try { + await invoke('clear_auth_token'); + console.log('[Desktop AuthService] Cleared Tauri keyring token'); + } catch (error) { + console.warn('[Desktop AuthService] Failed to clear Tauri keyring token', error); + } + + // Best effort: clear web storage + try { + localStorage.removeItem('stirling_jwt'); + console.log('[Desktop AuthService] Cleared localStorage token'); + } catch (error) { + console.warn('[Desktop AuthService] Failed to clear localStorage token', error); + } + } + + /** + * Local clear only (no backend calls) to reset auth state in desktop contexts + */ + async localClearAuth(): Promise { + await this.clearTokenEverywhere().catch(() => {}); + try { + await invoke('clear_user_info'); + } catch (err) { + console.warn('[Desktop AuthService] Failed to clear user info', err); + } + this.setAuthStatus('unauthenticated', null); } subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void { @@ -253,6 +283,41 @@ export class AuthService { try { console.log('Logging out'); + // Best-effort backend logout so any server-side session/cookies are cleared + try { + const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null); + const serverUrl = currentConfig?.server_config?.url; + const token = await this.getAuthToken(); + + if (serverUrl && token) { + const base = serverUrl.replace(/\/+$/, ''); + const headers: Record = { Authorization: `Bearer ${token}` }; + + // Treat 401/403 as benign (session already expired) + const safePost = async (url: string) => { + try { + const resp = await axios.post(url, null, { + headers, + withCredentials: true, + validateStatus: () => true, // handle status manually + }); + if (resp.status >= 400 && ![401, 403].includes(resp.status)) { + console.warn(`[Desktop AuthService] Logout call to ${url} failed: ${resp.status}`); + } + } catch (err) { + console.warn(`[Desktop AuthService] Backend logout failed via ${url}`, err); + } + }; + + await safePost(`${base}/api/v1/auth/logout`); + + // Also attempt framework logout endpoint to clear cookies/sessions + await safePost(`${base}/logout`); + } + } catch (err) { + console.warn('[Desktop AuthService] Failed to call backend logout endpoint', err); + } + // Clear token from all storage locations await this.clearTokenEverywhere(); @@ -367,6 +432,21 @@ export class AuthService { async initializeAuthState(): Promise { console.log('[Desktop AuthService] Initializing auth state...'); + // If we are on the login/setup screen, don't auto-restore a previous session; clear instead + const path = typeof window !== 'undefined' ? window.location.pathname : ''; + if (path.startsWith('/login') || path.startsWith('/setup')) { + console.log('[Desktop AuthService] On login/setup path, clearing any cached auth'); + // Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated + await this.clearTokenEverywhere().catch(() => {}); + try { + await invoke('clear_user_info'); + } catch (err) { + console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err); + } + this.setAuthStatus('unauthenticated', null); + return; + } + const token = await this.getAuthToken(); const userInfo = await this.getUserInfo(); @@ -382,6 +462,9 @@ export class AuthService { console.log('[Desktop AuthService] No token or user info found'); this.setAuthStatus('unauthenticated', null); console.log('[Desktop AuthService] Auth state initialized as unauthenticated'); + + // Defensive: ensure any partial tokens are purged to prevent auto-login loops + await this.clearTokenEverywhere().catch(() => {}); } } @@ -436,6 +519,180 @@ export class AuthService { } } + /** + * Self-hosted SSO/OAuth2 flow for the desktop app. +1 * Opens the system browser and waits for a deep link callback with the JWT. + */ + async loginWithSelfHostedOAuth(providerPath: string, serverUrl: string): Promise { + // Generate and store nonce for CSRF protection + const nonce = crypto.randomUUID(); + sessionStorage.setItem('oauth_nonce', nonce); + console.log('[Desktop AuthService] Generated OAuth nonce for CSRF protection'); + + const trimmedServer = serverUrl.replace(/\/+$/, ''); + const fullUrl = providerPath.startsWith('http') + ? providerPath + : `${trimmedServer}${providerPath.startsWith('/') ? providerPath : `/${providerPath}`}`; + let authUrl = fullUrl; + try { + const parsed = new URL(fullUrl); + parsed.searchParams.set('tauri', '1'); + parsed.searchParams.set('nonce', nonce); + authUrl = parsed.toString(); + } catch { + // ignore URL parsing failures + } + + // Open in system browser and wait for deep link callback + if (await this.openInSystemBrowser(authUrl)) { + return this.waitForDeepLinkCompletion(trimmedServer); + } + + throw new Error('Unable to open system browser for SSO. Please check your system settings.'); + } + + /** + * Wait for a deep-link event to complete self-hosted SSO after system browser OAuth + */ + private async waitForDeepLinkCompletion(serverUrl: string): Promise { + if (!isTauri()) { + throw new Error('Deep link authentication is only supported in Tauri desktop app.'); + } + + return new Promise((resolve, reject) => { + let completed = false; + let unlisten: (() => void) | null = null; + + const timeoutId = window.setTimeout(() => { + if (!completed) { + completed = true; + if (unlisten) unlisten(); + sessionStorage.removeItem('oauth_nonce'); + reject(new Error('SSO login timed out. Please try again.')); + } + }, 120_000); + + listen('deep-link', async (event) => { + const url = event.payload; + if (!url || completed) return; + try { + const parsed = new URL(url); + const hash = parsed.hash.replace(/^#/, ''); + const params = new URLSearchParams(hash); + const type = params.get('type') || parsed.searchParams.get('type'); + const error = params.get('error') || parsed.searchParams.get('error'); + if (type === 'sso-error' || error) { + completed = true; + if (unlisten) unlisten(); + clearTimeout(timeoutId); + sessionStorage.removeItem('oauth_nonce'); + reject(new Error(error || 'Authentication was not successful.')); + return; + } + if (type !== 'sso' && type !== 'sso-selfhosted') { + return; + } + const token = params.get('access_token') || parsed.searchParams.get('access_token'); + if (!token) { + return; + } + + // CSRF Protection: Validate nonce before accepting token + const nonceFromUrl = params.get('nonce') || parsed.searchParams.get('nonce'); + const storedNonce = sessionStorage.getItem('oauth_nonce'); + + if (!nonceFromUrl || !storedNonce || nonceFromUrl !== storedNonce) { + completed = true; + if (unlisten) unlisten(); + clearTimeout(timeoutId); + sessionStorage.removeItem('oauth_nonce'); + console.error('[Desktop AuthService] Nonce validation failed - potential CSRF attack'); + reject(new Error('Invalid authentication state. Nonce validation failed.')); + return; + } + + completed = true; + if (unlisten) unlisten(); + clearTimeout(timeoutId); + sessionStorage.removeItem('oauth_nonce'); + console.log('[Desktop AuthService] Nonce validated successfully'); + + const userInfo = await this.completeSelfHostedSession(serverUrl, token); + // Ensure connection mode is set and backend is ready (in case caller doesn't) + try { + await connectionModeService.switchToSelfHosted({ url: serverUrl }); + await tauriBackendService.initializeExternalBackend(); + } catch (e) { + console.warn('[Desktop AuthService] Failed to initialize backend after deep link:', e); + } + resolve(userInfo); + } catch (err) { + completed = true; + if (unlisten) unlisten(); + clearTimeout(timeoutId); + sessionStorage.removeItem('oauth_nonce'); + reject(err instanceof Error ? err : new Error('Failed to complete SSO')); + } + }).then((fn) => { + unlisten = fn; + }); + }); + } + + private async openInSystemBrowser(url: string): Promise { + if (!isTauri()) { + return false; + } + try { + // Prefer plugin-shell (2.x) if available + await shellOpen(url); + return true; + } catch (err) { + console.error('Failed to open system browser for SSO:', err); + return false; + } + } + + /** + * Save JWT + user info for self-hosted SSO logins + */ + async completeSelfHostedSession(serverUrl: string, token: string): Promise { + const userInfo = await this.fetchSelfHostedUserInfo(serverUrl, token); + + await this.saveTokenEverywhere(token); + await invoke('save_user_info', { + username: userInfo.username, + email: userInfo.email || null, + }); + + this.setAuthStatus('authenticated', userInfo); + return userInfo; + } + + private async fetchSelfHostedUserInfo(serverUrl: string, token: string): Promise { + try { + const response = await axios.get( + `${serverUrl.replace(/\/+$/, '')}/api/v1/auth/me`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const data = response.data; + const user = data.user || data; + + return { + username: user.username || user.email || 'User', + email: user.email || undefined, + }; + } catch (error) { + console.error('[Desktop AuthService] Failed to fetch user info after SSO:', error); + throw error; + } + } + /** * Fetch user info from Supabase using access token */ diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts index cf454a50b..c4db4d641 100644 --- a/frontend/src/desktop/services/connectionModeService.ts +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -3,9 +3,15 @@ import { fetch } from '@tauri-apps/plugin-http'; export type ConnectionMode = 'saas' | 'selfhosted'; +export interface SSOProviderConfig { + id: string; + path: string; + label?: string; +} + export interface ServerConfig { url: string; - enabledOAuthProviders?: string[]; + enabledOAuthProviders?: SSOProviderConfig[]; } export interface ConnectionConfig { diff --git a/frontend/src/proprietary/auth/UseSession.tsx b/frontend/src/proprietary/auth/UseSession.tsx index 64e5baef0..914708836 100644 --- a/frontend/src/proprietary/auth/UseSession.tsx +++ b/frontend/src/proprietary/auth/UseSession.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'; import { springAuth } from '@app/auth/springAuthClient'; +import { clearPlatformAuthOnLoginInit } from '@app/extensions/authSessionCleanup'; import type { Session, User, AuthError, AuthChangeEvent } from '@app/auth/springAuthClient'; /** @@ -79,6 +80,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { console.debug('[Auth] Signed out successfully'); setSession(null); } + } catch (err) { console.error('[Auth] Unexpected error during sign out:', err); setError(err as AuthError); @@ -94,6 +96,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const initializeAuth = async () => { try { console.debug('[Auth] Initializing auth...'); + // Clear any platform-specific cached auth on login page init. + if (typeof window !== 'undefined' && window.location.pathname.startsWith('/login')) { + await clearPlatformAuthOnLoginInit(); + } // Skip config check entirely - let the app handle login state // The config will be fetched by useAppConfig when needed diff --git a/frontend/src/proprietary/auth/oauthStorage.ts b/frontend/src/proprietary/auth/oauthStorage.ts new file mode 100644 index 000000000..17597d5e1 --- /dev/null +++ b/frontend/src/proprietary/auth/oauthStorage.ts @@ -0,0 +1,34 @@ +/** + * Helper utilities for clearing cached OAuth redirect/session state + */ + +const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path'; + +/** + * Clear any persisted OAuth redirect path/cached state so the app + * does not automatically resume a previous OAuth session after logout. + */ +export function resetOAuthState(): void { + try { + // Remove redirect cookie + if (typeof document !== 'undefined') { + document.cookie = `${OAUTH_REDIRECT_COOKIE}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`; + } + } catch (err) { + console.warn('[OAuthStorage] Failed to clear redirect cookie', err); + } + + // Remove any related localStorage entries we might have used + try { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.removeItem(OAUTH_REDIRECT_COOKIE); + window.localStorage.removeItem('oauth_redirect_path'); + } + } catch (err) { + console.warn('[OAuthStorage] Failed to clear OAuth localStorage', err); + } +} + +export default { + resetOAuthState, +}; diff --git a/frontend/src/proprietary/auth/springAuthClient.test.ts b/frontend/src/proprietary/auth/springAuthClient.test.ts index e3db60338..0a874cd4e 100644 --- a/frontend/src/proprietary/auth/springAuthClient.test.ts +++ b/frontend/src/proprietary/auth/springAuthClient.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { springAuth } from '@app/auth/springAuthClient'; +import { startOAuthNavigation } from '@app/extensions/oauthNavigation'; import apiClient from '@app/services/apiClient'; import { AxiosError } from 'axios'; // Mock apiClient vi.mock('@app/services/apiClient'); +vi.mock('@app/extensions/oauthNavigation', () => ({ + startOAuthNavigation: vi.fn().mockResolvedValue(false), +})); describe('SpringAuthClient', () => { beforeEach(() => { @@ -342,13 +346,35 @@ describe('SpringAuthClient', () => { writable: true, }); + vi.mocked(startOAuthNavigation).mockResolvedValueOnce(false); + const result = await springAuth.signInWithOAuth({ provider: '/oauth2/authorization/github', options: { redirectTo: '/auth/callback' }, }); + expect(startOAuthNavigation).toHaveBeenCalledWith('/oauth2/authorization/github'); expect(mockAssign).toHaveBeenCalledWith('/oauth2/authorization/github'); expect(result.error).toBeNull(); }); + + it('should skip redirect when handled by extension', async () => { + const mockAssign = vi.fn(); + Object.defineProperty(window, 'location', { + value: { assign: mockAssign }, + writable: true, + }); + + vi.mocked(startOAuthNavigation).mockResolvedValueOnce(true); + + const result = await springAuth.signInWithOAuth({ + provider: '/oauth2/authorization/github', + options: { redirectTo: '/auth/callback' }, + }); + + expect(startOAuthNavigation).toHaveBeenCalledWith('/oauth2/authorization/github'); + expect(mockAssign).not.toHaveBeenCalled(); + expect(result.error).toBeNull(); + }); }); }); diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index f77e34cb6..573d9b101 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -11,6 +11,9 @@ import apiClient from '@app/services/apiClient'; import { AxiosError } from 'axios'; import { BASE_PATH } from '@app/constants/app'; import { type OAuthProvider } from '@app/auth/oauthTypes'; +import { resetOAuthState } from '@app/auth/oauthStorage'; +import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup'; +import { startOAuthNavigation } from '@app/extensions/oauthNavigation'; // Helper to extract error message from axios error function getErrorMessage(error: unknown, fallback: string): string { @@ -267,6 +270,10 @@ class SpringAuthClient { // Use the full path provided by the backend // This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...) const redirectUrl = params.provider; + const handled = await startOAuthNavigation(redirectUrl); + if (handled) { + return { error: null }; + } // console.log('[SpringAuth] Redirecting to SSO:', redirectUrl); // Use window.location.assign for full page navigation window.location.assign(redirectUrl); @@ -296,6 +303,35 @@ class SpringAuthClient { // Clean up local storage localStorage.removeItem('stirling_jwt'); + try { + Object.keys(localStorage) + .filter((key) => key.startsWith('sb-') || key.includes('supabase')) + .forEach((key) => localStorage.removeItem(key)); + + // Clear any cached OAuth redirect/session state + resetOAuthState(); + } catch (err) { + console.warn('[SpringAuth] Failed to clear Supabase/local auth tokens', err); + } + + // Clear cookies that might hold refresh/session tokens + try { + document.cookie.split(';').forEach(cookie => { + const eqPos = cookie.indexOf('='); + const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); + if (name) { + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;`; + } + }); + } catch (err) { + console.warn('[SpringAuth] Failed to clear cookies on sign out', err); + } + + try { + await clearPlatformAuthAfterSignOut(); + } catch (cleanupError) { + console.warn('[SpringAuth] Failed to run platform auth cleanup', cleanupError); + } // Notify listeners this.notifyListeners('SIGNED_OUT', null); @@ -305,6 +341,11 @@ class SpringAuthClient { console.error('[SpringAuth] signOut error:', error); // Still remove token even if backend call fails localStorage.removeItem('stirling_jwt'); + try { + await clearPlatformAuthAfterSignOut(); + } catch (cleanupError) { + console.warn('[SpringAuth] Failed to run platform auth cleanup after error', cleanupError); + } return { error: { message: getErrorMessage(error, 'Logout failed') }, }; diff --git a/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx index 95a9ee57f..81130ec69 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx @@ -6,10 +6,12 @@ import { alert as showToast } from '@app/components/toast'; import { useAuth } from '@app/auth/UseSession'; import { accountService } from '@app/services/accountService'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import { useAccountLogout } from '@app/extensions/accountLogout'; const AccountSection: React.FC = () => { const { t } = useTranslation(); const { user, signOut } = useAuth(); + const accountLogout = useAccountLogout(); const [passwordModalOpen, setPasswordModalOpen] = useState(false); const [usernameModalOpen, setUsernameModalOpen] = useState(false); @@ -42,12 +44,8 @@ const AccountSection: React.FC = () => { }, []); const handleLogout = useCallback(async () => { - try { - await signOut(); - } finally { - redirectToLogin(); - } - }, [redirectToLogin, signOut]); + await accountLogout({ signOut, redirectToLogin }); + }, [accountLogout, redirectToLogin, signOut]); const handlePasswordSubmit = async (event: React.FormEvent) => { event.preventDefault(); diff --git a/frontend/src/proprietary/extensions/accountLogout.ts b/frontend/src/proprietary/extensions/accountLogout.ts new file mode 100644 index 000000000..fbff0108e --- /dev/null +++ b/frontend/src/proprietary/extensions/accountLogout.ts @@ -0,0 +1,20 @@ +type SignOutFn = () => Promise; + +interface AccountLogoutDeps { + signOut: SignOutFn; + redirectToLogin: () => void; +} + +/** + * Default (web/proprietary) logout handler: sign out and redirect to /login. + * Desktop builds override this file via path resolution. + */ +export function useAccountLogout() { + return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise => { + try { + await signOut(); + } finally { + redirectToLogin(); + } + }; +} diff --git a/frontend/src/proprietary/extensions/authCallback.ts b/frontend/src/proprietary/extensions/authCallback.ts new file mode 100644 index 000000000..db9a87ea6 --- /dev/null +++ b/frontend/src/proprietary/extensions/authCallback.ts @@ -0,0 +1,7 @@ +/** + * Extension hook for platform-specific OAuth callback handling. + * Proprietary/web builds are no-op. + */ +export async function handleAuthCallbackSuccess(_token: string): Promise { + // no-op for web builds +} diff --git a/frontend/src/proprietary/extensions/authSessionCleanup.ts b/frontend/src/proprietary/extensions/authSessionCleanup.ts new file mode 100644 index 000000000..6cd8a7af1 --- /dev/null +++ b/frontend/src/proprietary/extensions/authSessionCleanup.ts @@ -0,0 +1,11 @@ +/** + * Extension hooks for platform-specific auth cleanup. + * Proprietary/web builds are no-op. + */ +export async function clearPlatformAuthAfterSignOut(): Promise { + // no-op for web builds +} + +export async function clearPlatformAuthOnLoginInit(): Promise { + // no-op for web builds +} diff --git a/frontend/src/proprietary/extensions/oauthNavigation.ts b/frontend/src/proprietary/extensions/oauthNavigation.ts new file mode 100644 index 000000000..65dc612f2 --- /dev/null +++ b/frontend/src/proprietary/extensions/oauthNavigation.ts @@ -0,0 +1,7 @@ +/** + * Extension hook for platform-specific OAuth navigation. + * Proprietary/web builds default to in-window navigation. + */ +export async function startOAuthNavigation(_redirectUrl: string): Promise { + return false; +} diff --git a/frontend/src/proprietary/routes/AuthCallback.module.css b/frontend/src/proprietary/routes/AuthCallback.module.css new file mode 100644 index 000000000..71d2e5d6d --- /dev/null +++ b/frontend/src/proprietary/routes/AuthCallback.module.css @@ -0,0 +1,129 @@ +.page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 50px 20px; + background: #f5f5f5; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +.card { + background: #ffffff; + border-radius: 12px; + padding: 40px; + max-width: 420px; + width: 100%; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + text-align: center; + color: #1a1a1a; + border: 1px solid #e5e7eb; +} + +.icon { + font-size: 48px; + margin-bottom: 16px; +} + +.iconSuccess { + color: #2e7d32; +} + +.iconError { + color: #d32f2f; +} + +.iconNeutral { + color: #4b5563; +} + +.title { + font-size: 24px; + font-weight: 600; + margin-bottom: 12px; + color: #1a1a1a; +} + +.message { + color: #666; + line-height: 1.6; + font-size: 15px; +} + +.loadingExtra { + color: #6b7280; + margin-top: 12px; + font-size: 14px; +} + +.errorBox { + margin-top: 20px; + background: #ffebee; + border: 1px solid #ffcdd2; + border-radius: 8px; + padding: 16px; + color: #c62828; + font-size: 14px; + line-height: 1.5; + word-break: break-word; + text-align: left; +} + +@media (prefers-color-scheme: dark) { + .page { + background: #1a1a1a; + color: #e0e0e0; + } + + .card { + background: #2d2d2d; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + color: #e5e7eb; + border-color: #374151; + } + + .iconSuccess { + color: #66bb6a; + } + + .iconError { + color: #ef5350; + } + + .iconNeutral { + color: #9ca3af; + } + + .title { + color: #f5f5f5; + } + + .message, + .loadingExtra { + color: #b0b0b0; + } + + .errorBox { + background: #3d2020; + border: 1px solid #5d3030; + color: #ef9a9a; + } +} + +@media (max-width: 480px) { + .page { + padding: 20px 16px; + } + + .card { + padding: 32px 24px; + } + + .title { + font-size: 20px; + } + + .icon { + font-size: 40px; + } +} diff --git a/frontend/src/proprietary/routes/AuthCallback.test.tsx b/frontend/src/proprietary/routes/AuthCallback.test.tsx index 25bca74f6..ef540d36b 100644 --- a/frontend/src/proprietary/routes/AuthCallback.test.tsx +++ b/frontend/src/proprietary/routes/AuthCallback.test.tsx @@ -172,6 +172,6 @@ describe('AuthCallback', () => { ); - expect(getByText('Completing authentication...')).toBeInTheDocument(); + expect(getByText('Completing authentication')).toBeInTheDocument(); }); }); diff --git a/frontend/src/proprietary/routes/AuthCallback.tsx b/frontend/src/proprietary/routes/AuthCallback.tsx index 488a54146..205fd3414 100644 --- a/frontend/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/src/proprietary/routes/AuthCallback.tsx @@ -1,6 +1,8 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { springAuth } from '@app/auth/springAuthClient'; +import { handleAuthCallbackSuccess } from '@app/extensions/authCallback'; +import styles from '@app/routes/AuthCallback.module.css'; /** * OAuth Callback Handler @@ -52,6 +54,8 @@ export default function AuthCallback() { return; } + await handleAuthCallbackSuccess(token); + console.log('[AuthCallback] Token validated, redirecting to home'); // Clear the hash from URL and redirect to home page @@ -69,17 +73,12 @@ export default function AuthCallback() { }, [navigate]); return ( -
-
-
-
- Completing authentication... -
+
+
+
...
+
Completing authentication
+
Please wait while we finish signing you in.
+
You can close this window once it completes.
);