From f5c67a3239d2d932d3c6c02a6c68769703dee7c7 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Thu, 6 Nov 2025 15:42:22 +0000 Subject: [PATCH] Login Refresh Fix (#4779) Main Issues Fixed: 1. Tools Disabled on Initial Login (Required Page Refresh) Problem: After successful login, all PDF tools appeared grayed out/disabled until the user refreshed the page. Root Cause: Race condition where tools checked endpoint availability before JWT was stored in localStorage. Fix: - Implemented optimistic defaults in useEndpointConfig - assumes endpoints are enabled when no JWT exists - Added JWT availability event system (jwt-available event) to notify components when authentication is ready - Tools now remain enabled during auth initialization instead of defaulting to disabled 2. Session Lost on Page Refresh (Immediate Logout) Problem: Users were immediately logged out when refreshing the page, losing their authenticated session. Root Causes: - Spring Security form login was redirecting API calls to /login with 302 responses instead of returning JSON - /api/v1/auth/me endpoint was incorrectly in the permitAll list - JWT filter wasn't allowing /api/v1/config endpoints without authentication Fixes: - Backend: Disabled form login in v2/JWT mode by adding && !v2Enabled condition to form login configuration - Backend: Removed /api/v1/auth/me from permitAll list - it now requires authentication - Backend: Added /api/v1/config to public endpoints in JWT filter - Backend: Configured proper exception handling for API endpoints to return JSON (401) instead of HTML redirects (302) 3. Multiple Duplicate API Calls Problem: After login, /app-config was called 5+ times, /endpoints-enabled and /me called multiple times, causing unnecessary network traffic. Root Cause: Multiple React components each had their own instance of useAppConfig and useEndpointConfig hooks, each fetching data independently. Fix: - Frontend: Created singleton AppConfigContext provider to ensure only one global config fetch - Frontend: Added global caching to useEndpointConfig with module-level cache variables - Frontend: Implemented fetch deduplication with fetchCount tracking and globalFetchedSets - Result: Reduced API calls from 5+ to 1-2 per endpoint (2 in dev due to React StrictMode) Additional Improvements: CORS Configuration - Added flexible CORS configuration matching SaaS pattern - Explicitly allows localhost development ports (3000, 5173, 5174, etc.) - No hardcoded URLs in application.properties Security Handlers Integration - Added IP-based account locking without dependency on form login - Preserved audit logging with @Audited annotations Key Code Changes: Backend Files: - SecurityConfiguration.java - Disabled form login for v2, added CORS config - JwtAuthenticationFilter.java - Added /api/v1/config to public endpoints - JwtAuthenticationEntryPoint.java - Returns JSON for API requests Frontend Files: - AppConfigContext.tsx - New singleton context for app configuration - useEndpointConfig.ts - Added global caching and deduplication - UseSession.tsx - Removed redundant config checking - Various hooks - Updated to use context providers instead of direct fetching --------- Signed-off-by: dependabot[bot] Signed-off-by: stirlingbot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ludy Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Co-authored-by: Ethan Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Co-authored-by: Connor Yoh --- .../software/common/util/RequestUriUtils.java | 29 ++++ .../security/JwtAuthenticationEntryPoint.java | 16 +- .../configuration/SecurityConfiguration.java | 153 ++++++++++++----- .../controller/api/AuthController.java | 49 ++++-- .../filter/JwtAuthenticationFilter.java | 13 +- .../filter/UserAuthenticationFilter.java | 29 +++- ...tomOAuth2AuthenticationSuccessHandler.java | 158 ++++++++++++++++-- .../security/oauth2/OAuth2Configuration.java | 7 +- ...stomSaml2AuthenticationSuccessHandler.java | 124 +++++++++++++- .../JwtAuthenticationEntryPointTest.java | 2 + frontend/package-lock.json | 42 ++++- .../src/core/contexts/AppConfigContext.tsx | 65 +++++-- frontend/src/core/hooks/useEndpointConfig.ts | 135 +++++++++++---- frontend/src/core/hooks/useToolManagement.tsx | 10 +- frontend/src/proprietary/auth/UseSession.tsx | 19 +-- .../src/proprietary/auth/springAuthClient.ts | 70 +++++++- .../src/proprietary/routes/AuthCallback.tsx | 3 + frontend/vite.config.ts | 33 ++-- 18 files changed, 760 insertions(+), 197 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 239976b66..321606186 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -49,4 +49,33 @@ public class RequestUriUtils { || requestURI.startsWith("/fonts") || requestURI.startsWith("/pdfjs")); } + + /** + * Checks if the request URI is a public authentication endpoint that doesn't require + * authentication. This includes login, signup, OAuth callbacks, and public config endpoints. + * + * @param requestURI The full request URI + * @param contextPath The servlet context path + * @return true if the endpoint is public and doesn't require authentication + */ + public static boolean isPublicAuthEndpoint(String requestURI, String contextPath) { + // Remove context path from URI to normalize path matching + String trimmedUri = + requestURI.startsWith(contextPath) + ? requestURI.substring(contextPath.length()) + : requestURI; + + // Public auth endpoints that don't require authentication + return trimmedUri.startsWith("/login") + || trimmedUri.startsWith("/auth/") + || trimmedUri.startsWith("/oauth2") + || trimmedUri.startsWith("/saml2") + || trimmedUri.contains("/login/oauth2/code/") // Spring Security OAuth2 callback + || trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint + || trimmedUri.startsWith("/api/v1/auth/login") + || trimmedUri.startsWith("/api/v1/auth/refresh") + || trimmedUri.startsWith("/api/v1/auth/logout") + || trimmedUri.startsWith("/v1/api-docs") + || trimmedUri.contains("/v1/api-docs"); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java index 6805bcb54..479b544ad 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java @@ -17,6 +17,20 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { HttpServletResponse response, AuthenticationException authException) throws IOException { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + String contextPath = request.getContextPath(); + String requestURI = request.getRequestURI(); + + // For API requests, return JSON error + if (requestURI.startsWith(contextPath + "/api/")) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + String message = + authException != null ? authException.getMessage() : "Authentication required"; + response.getWriter().write("{\"error\":\"" + message + "\"}"); + } else { + // For non-API requests, use default behavior + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + } } } 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 92def884f..010c15e29 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 @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.configuration; +import java.util.List; import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; @@ -28,11 +29,15 @@ import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.savedrequest.NullRequestCache; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.CustomAuthenticationFailureHandler; import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler; import stirling.software.proprietary.security.CustomLogoutSuccessHandler; @@ -67,6 +72,7 @@ public class SecurityConfiguration { private final boolean loginEnabledValue; private final boolean runningProOrHigher; + private final ApplicationProperties applicationProperties; private final ApplicationProperties.Security securityProperties; private final AppConfig appConfig; private final UserAuthenticationFilter userAuthenticationFilter; @@ -86,6 +92,7 @@ public class SecurityConfiguration { @Qualifier("loginEnabled") boolean loginEnabledValue, @Qualifier("runningProOrHigher") boolean runningProOrHigher, AppConfig appConfig, + ApplicationProperties applicationProperties, ApplicationProperties.Security securityProperties, UserAuthenticationFilter userAuthenticationFilter, JwtServiceInterface jwtService, @@ -102,6 +109,7 @@ public class SecurityConfiguration { this.loginEnabledValue = loginEnabledValue; this.runningProOrHigher = runningProOrHigher; this.appConfig = appConfig; + this.applicationProperties = applicationProperties; this.securityProperties = securityProperties; this.userAuthenticationFilter = userAuthenticationFilter; this.jwtService = jwtService; @@ -120,7 +128,79 @@ public class SecurityConfiguration { } @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public CorsConfigurationSource corsConfigurationSource() { + // Read CORS allowed origins from settings + if (applicationProperties.getSystem() != null + && applicationProperties.getSystem().getCorsAllowedOrigins() != null + && !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) { + + List allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins(); + + CorsConfiguration cfg = new CorsConfiguration(); + + // Use setAllowedOriginPatterns for better wildcard and port support + cfg.setAllowedOriginPatterns(allowedOrigins); + log.debug( + "CORS configured with allowed origin patterns from settings.yml: {}", + allowedOrigins); + + // Set allowed methods explicitly (including OPTIONS for preflight) + cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + + // Set allowed headers explicitly + cfg.setAllowedHeaders( + List.of( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "X-API-KEY", + "X-CSRF-TOKEN")); + + // Set exposed headers (headers that the browser can access) + cfg.setExposedHeaders( + List.of( + "WWW-Authenticate", + "X-Total-Count", + "X-Page-Number", + "X-Page-Size", + "Content-Disposition", + "Content-Type")); + + // Allow credentials (cookies, authorization headers) + cfg.setAllowCredentials(true); + + // Set max age for preflight cache + cfg.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", cfg); + return source; + } else { + // No CORS origins configured - return null to disable CORS processing entirely + // This avoids empty CORS policy that unexpectedly rejects preflights + log.info( + "CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)"); + return null; + } + } + + @Bean + public SecurityFilterChain filterChain( + HttpSecurity http, + @Lazy IPRateLimitingFilter rateLimitingFilter, + @Lazy JwtAuthenticationFilter jwtAuthenticationFilter) + throws Exception { + // Enable CORS only if we have configured origins + CorsConfigurationSource corsSource = corsConfigurationSource(); + if (corsSource != null) { + http.cors(cors -> cors.configurationSource(corsSource)); + } else { + // Explicitly disable CORS when no origins are configured + http.cors(cors -> cors.disable()); + } + if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { http.csrf(CsrfConfigurer::disable); } @@ -130,12 +210,8 @@ public class SecurityConfiguration { http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore( - rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); - - if (v2Enabled) { - http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class); - } + .addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class); if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = @@ -195,6 +271,18 @@ public class SecurityConfiguration { }); http.authenticationProvider(daoAuthenticationProvider()); http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache())); + + // Configure exception handling for API endpoints + http.exceptionHandling( + exceptions -> + exceptions.defaultAuthenticationEntryPointFor( + jwtAuthenticationEntryPoint, + request -> { + String contextPath = request.getContextPath(); + String requestURI = request.getRequestURI(); + return requestURI.startsWith(contextPath + "/api/"); + })); + http.logout( logout -> logout.logoutRequestMatcher( @@ -228,43 +316,12 @@ public class SecurityConfiguration { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); - // Remove the context path from the URI - String trimmedUri = - uri.startsWith(contextPath) - ? uri.substring( - contextPath.length()) - : uri; - return trimmedUri.startsWith("/login") - || trimmedUri.startsWith("/oauth") - || trimmedUri.startsWith("/oauth2") - || trimmedUri.startsWith("/saml2") - || trimmedUri.endsWith(".svg") - || trimmedUri.startsWith("/register") - || trimmedUri.startsWith("/signup") - || trimmedUri.startsWith("/auth/callback") - || trimmedUri.startsWith("/error") - || trimmedUri.startsWith("/images/") - || trimmedUri.startsWith("/public/") - || trimmedUri.startsWith("/css/") - || trimmedUri.startsWith("/fonts/") - || trimmedUri.startsWith("/js/") - || trimmedUri.startsWith("/pdfjs/") - || trimmedUri.startsWith("/pdfjs-legacy/") - || trimmedUri.startsWith("/favicon") - || trimmedUri.startsWith( - "/api/v1/info/status") - || trimmedUri.startsWith("/api/v1/config") - || trimmedUri.startsWith( - "/api/v1/auth/register") - || trimmedUri.startsWith( - "/api/v1/user/register") - || trimmedUri.startsWith( - "/api/v1/auth/login") - || trimmedUri.startsWith( - "/api/v1/auth/refresh") - || trimmedUri.startsWith("/api/v1/auth/me") - || trimmedUri.startsWith("/v1/api-docs") - || uri.contains("/v1/api-docs"); + // Check if it's a public auth endpoint or static + // resource + return RequestUriUtils.isStaticResource( + contextPath, uri) + || RequestUriUtils.isPublicAuthEndpoint( + uri, contextPath); }) .permitAll() .anyRequest() @@ -333,8 +390,12 @@ public class SecurityConfiguration { .saml2Login( saml2 -> { try { - saml2.loginPage("/saml2") - .relyingPartyRegistrationRepository( + // Only set login page for v1/Thymeleaf mode + if (!v2Enabled) { + saml2.loginPage("/saml2"); + } + + saml2.relyingPartyRegistrationRepository( saml2RelyingPartyRegistrations) .authenticationManager( new ProviderManager(authenticationProvider)) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 0dd8ee4bf..de6428554 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -21,11 +21,15 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; /** REST API Controller for authentication operations. */ @@ -39,6 +43,7 @@ public class AuthController { private final UserService userService; private final JwtServiceInterface jwtService; private final CustomUserDetailsService userDetailsService; + private final LoginAttemptService loginAttemptService; /** * Login endpoint - replaces Supabase signInWithPassword @@ -49,8 +54,11 @@ public class AuthController { */ @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/login") + @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public ResponseEntity login( - @RequestBody UsernameAndPass request, HttpServletResponse response) { + @RequestBody UsernameAndPass request, + HttpServletRequest httpRequest, + HttpServletResponse response) { try { // Validate input parameters if (request.getUsername() == null || request.getUsername().trim().isEmpty()) { @@ -67,20 +75,30 @@ public class AuthController { .body(Map.of("error", "Password is required")); } - log.debug("Login attempt for user: {}", request.getUsername()); + String username = request.getUsername().trim(); + String ip = httpRequest.getRemoteAddr(); - UserDetails userDetails = - userDetailsService.loadUserByUsername(request.getUsername().trim()); + // Check if account is blocked due to too many failed attempts + if (loginAttemptService.isBlocked(username)) { + log.warn("Blocked account login attempt for user: {} from IP: {}", username, ip); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Account is locked due to too many failed attempts")); + } + + log.debug("Login attempt for user: {} from IP: {}", username, ip); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); User user = (User) userDetails; if (!userService.isPasswordCorrect(user, request.getPassword())) { - log.warn("Invalid password for user: {}", request.getUsername()); + log.warn("Invalid password for user: {} from IP: {}", username, ip); + loginAttemptService.loginFailed(username); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "Invalid credentials")); } if (!user.isEnabled()) { - log.warn("Disabled user attempted login: {}", request.getUsername()); + log.warn("Disabled user attempted login: {} from IP: {}", username, ip); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "User account is disabled")); } @@ -91,7 +109,9 @@ public class AuthController { String token = jwtService.generateToken(user.getUsername(), claims); - log.info("Login successful for user: {}", request.getUsername()); + // Record successful login + loginAttemptService.loginSucceeded(username); + log.info("Login successful for user: {} from IP: {}", username, ip); return ResponseEntity.ok( Map.of( @@ -99,11 +119,15 @@ public class AuthController { "session", Map.of("access_token", token, "expires_in", 3600))); } catch (UsernameNotFoundException e) { - log.warn("User not found: {}", request.getUsername()); + String username = request.getUsername(); + log.warn("User not found: {}", username); + loginAttemptService.loginFailed(username); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "Invalid username or password")); } catch (AuthenticationException e) { - log.error("Authentication failed for user: {}", request.getUsername(), e); + String username = request.getUsername(); + log.error("Authentication failed for user: {}", username, e); + loginAttemptService.loginFailed(username); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "Invalid credentials")); } catch (Exception e) { @@ -228,11 +252,4 @@ public class AuthController { return userMap; } - - // =========================== - // Request/Response DTOs - // =========================== - - /** Login request DTO */ - public record LoginRequest(String email, String password) {} } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index d6a34264f..ace7d3318 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.filter; +import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint; import static stirling.software.common.util.RequestUriUtils.isStaticResource; import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2; import static stirling.software.proprietary.security.model.AuthenticationType.SAML2; @@ -80,17 +81,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String requestURI = request.getRequestURI(); String contextPath = request.getContextPath(); - // Public auth endpoints that don't require JWT - boolean isPublicAuthEndpoint = - requestURI.startsWith(contextPath + "/login") - || requestURI.startsWith(contextPath + "/signup") - || requestURI.startsWith(contextPath + "/auth/") - || requestURI.startsWith(contextPath + "/oauth2") - || requestURI.startsWith(contextPath + "/api/v1/auth/login") - || requestURI.startsWith(contextPath + "/api/v1/auth/register") - || requestURI.startsWith(contextPath + "/api/v1/auth/refresh"); - - if (!isPublicAuthEndpoint) { + if (!isPublicAuthEndpoint(requestURI, contextPath)) { // For API requests, return 401 JSON String acceptHeader = request.getHeader("Accept"); if (requestURI.startsWith(contextPath + "/api/") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 6a32511b0..6265281d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -1,5 +1,7 @@ package stirling.software.proprietary.security.filter; +import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint; + import java.io.IOException; import java.util.List; import java.util.Optional; @@ -105,11 +107,17 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } } - // If we still don't have any authentication, deny the request + // If we still don't have any authentication, check if it's a public endpoint. If not, deny the request if (authentication == null || !authentication.isAuthenticated()) { String method = request.getMethod(); String contextPath = request.getContextPath(); + // Allow public auth endpoints to pass through without authentication + if (isPublicAuthEndpoint(requestURI, contextPath)) { + filterChain.doFilter(request, response); + return; + } + if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) { response.sendRedirect(contextPath + "/login"); // redirect to the login page } else { @@ -200,6 +208,23 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } + private static boolean isPublicAuthEndpoint(String requestURI, String contextPath) { + // Remove context path from URI to normalize path matching + String trimmedUri = + requestURI.startsWith(contextPath) + ? requestURI.substring(contextPath.length()) + : requestURI; + + // Public auth endpoints that don't require authentication + return trimmedUri.startsWith("/login") + || trimmedUri.startsWith("/auth/") + || trimmedUri.startsWith("/oauth2") + || trimmedUri.startsWith("/saml2") + || trimmedUri.startsWith("/api/v1/auth/login") + || trimmedUri.startsWith("/api/v1/auth/refresh") + || trimmedUri.startsWith("/api/v1/auth/logout"); + } + private enum UserLoginType { USERDETAILS("UserDetails"), OAUTH2USER("OAuth2User"), @@ -225,7 +250,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { String contextPath = request.getContextPath(); String[] permitAllPatterns = { contextPath + "/login", - contextPath + "/signup", contextPath + "/register", contextPath + "/error", contextPath + "/images/", @@ -237,7 +261,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { contextPath + "/pdfjs-legacy/", contextPath + "/api/v1/info/status", contextPath + "/api/v1/auth/login", - contextPath + "/api/v1/auth/register", contextPath + "/api/v1/auth/refresh", contextPath + "/api/v1/auth/me", contextPath + "/site.webmanifest" 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 2afc43443..d2e03a04e 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,9 +4,14 @@ 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; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; @@ -16,6 +21,7 @@ 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; @@ -37,6 +43,9 @@ 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; @@ -119,7 +128,8 @@ public class CustomOAuth2AuthenticationSuccessHandler authentication, Map.of("authType", AuthenticationType.OAUTH2)); // Build context-aware redirect URL based on the original request - String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt); + String redirectUrl = + buildContextAwareRedirectUrl(request, response, contextPath, jwt); response.sendRedirect(redirectUrl); } else { @@ -149,30 +159,110 @@ public class CustomOAuth2AuthenticationSuccessHandler * Builds a context-aware redirect URL based on the request's origin * * @param request The HTTP request + * @param response HTTP response (used to clear redirect cookies) * @param contextPath The application context path * @param jwt The JWT token to include * @return The appropriate redirect URL */ private String buildContextAwareRedirectUrl( - HttpServletRequest request, String contextPath, String jwt) { - // Try to get the origin from the Referer header first + HttpServletRequest request, + HttpServletResponse response, + String contextPath, + String jwt) { + String redirectPath = resolveRedirectPath(request, contextPath); + String origin = + resolveForwardedOrigin(request) + .orElseGet( + () -> + resolveOriginFromReferer(request) + .orElseGet(() -> buildOriginFromRequest(request))); + clearRedirectCookie(response); + return origin + redirectPath + "#access_token=" + jwt; + } + + 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(); + } + 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); + } + } + } + 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; + } + + private Optional resolveForwardedOrigin(HttpServletRequest request) { + String forwardedHostHeader = request.getHeader("X-Forwarded-Host"); + if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) { + return Optional.empty(); + } + String host = forwardedHostHeader.split(",")[0].trim(); + if (host.isEmpty()) { + return Optional.empty(); + } + + String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto"); + String proto = + (forwardedProtoHeader == null || forwardedProtoHeader.isBlank()) + ? request.getScheme() + : forwardedProtoHeader.split(",")[0].trim(); + + if (!host.contains(":")) { + String forwardedPort = request.getHeader("X-Forwarded-Port"); + if (forwardedPort != null + && !forwardedPort.isBlank() + && !isDefaultPort(proto, forwardedPort.trim())) { + host = host + ":" + forwardedPort.trim(); + } + } + return Optional.of(proto + "://" + host); + } + + private Optional resolveOriginFromReferer(HttpServletRequest request) { String referer = request.getHeader("Referer"); if (referer != null && !referer.isEmpty()) { try { java.net.URL refererUrl = new java.net.URL(referer); - String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost(); - if (refererUrl.getPort() != -1 - && refererUrl.getPort() != 80 - && refererUrl.getPort() != 443) { - origin += ":" + refererUrl.getPort(); + String refererHost = refererUrl.getHost().toLowerCase(); + + if (!isOAuthProviderDomain(refererHost)) { + String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost(); + if (refererUrl.getPort() != -1 + && refererUrl.getPort() != 80 + && refererUrl.getPort() != 443) { + origin += ":" + refererUrl.getPort(); + } + return Optional.of(origin); } - return origin + "/auth/callback#access_token=" + jwt; } catch (java.net.MalformedURLException e) { - // Fall back to other methods if referer is malformed + // ignore and fall back } } + return Optional.empty(); + } - // Fall back to building from request host/port + private String buildOriginFromRequest(HttpServletRequest request) { String scheme = request.getScheme(); String serverName = request.getServerName(); int serverPort = request.getServerPort(); @@ -180,12 +270,50 @@ public class CustomOAuth2AuthenticationSuccessHandler StringBuilder origin = new StringBuilder(); origin.append(scheme).append("://").append(serverName); - // Only add port if it's not the default port for the scheme - if ((!"http".equals(scheme) || serverPort != 80) - && (!"https".equals(scheme) || serverPort != 443)) { + if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80) + && (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) { origin.append(":").append(serverPort); } - return origin.toString() + "/auth/callback#access_token=" + jwt; + return origin.toString(); + } + + private boolean isDefaultPort(String scheme, String port) { + if (port == null) { + return true; + } + try { + int parsedPort = Integer.parseInt(port); + return ("http".equalsIgnoreCase(scheme) && parsedPort == 80) + || ("https".equalsIgnoreCase(scheme) && parsedPort == 443); + } catch (NumberFormatException e) { + return false; + } + } + + private void clearRedirectCookie(HttpServletResponse response) { + ResponseCookie cookie = + ResponseCookie.from(SPA_REDIRECT_COOKIE, "") + .path("/") + .sameSite("Lax") + .maxAge(0) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + /** + * Checks if the given hostname belongs to a known OAuth provider. + * + * @param hostname The hostname to check + * @return true if it's an OAuth provider domain, false otherwise + */ + private boolean isOAuthProviderDomain(String hostname) { + return hostname.contains("google.com") + || hostname.contains("googleapis.com") + || hostname.contains("github.com") + || hostname.contains("microsoft.com") + || hostname.contains("microsoftonline.com") + || hostname.contains("linkedin.com") + || hostname.contains("apple.com"); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java index cd04d6da0..a053c1ead 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java @@ -165,12 +165,7 @@ public class OAuth2Configuration { githubClient.getUseAsUsername()); boolean isValid = validateProvider(github); - log.info( - "GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})", - isValid, - githubClient.getClientId(), - githubClient.getClientSecret() != null ? "***" : "null", - githubClient.getScopes()); + log.info("Initialised GitHub OAuth2 provider"); return isValid ? Optional.of( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index e7a47a391..af6d284cf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -4,15 +4,21 @@ import static stirling.software.proprietary.security.model.AuthenticationType.SA 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; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; 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; @@ -36,6 +42,9 @@ import stirling.software.proprietary.security.service.UserService; public class CustomSaml2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path"; + private static final String DEFAULT_CALLBACK_PATH = "/auth/callback"; + private LoginAttemptService loginAttemptService; private ApplicationProperties.Security.SAML2 saml2Properties; private UserService userService; @@ -148,7 +157,7 @@ public class CustomSaml2AuthenticationSuccessHandler // Build context-aware redirect URL based on the original request String redirectUrl = - buildContextAwareRedirectUrl(request, contextPath, jwt); + buildContextAwareRedirectUrl(request, response, contextPath, jwt); response.sendRedirect(redirectUrl); } else { @@ -177,8 +186,81 @@ public class CustomSaml2AuthenticationSuccessHandler * @return The appropriate redirect URL */ private String buildContextAwareRedirectUrl( - HttpServletRequest request, String contextPath, String jwt) { - // Try to get the origin from the Referer header first + HttpServletRequest request, + HttpServletResponse response, + String contextPath, + String jwt) { + String redirectPath = resolveRedirectPath(request, contextPath); + String origin = + resolveForwardedOrigin(request) + .orElseGet( + () -> + resolveOriginFromReferer(request) + .orElseGet(() -> buildOriginFromRequest(request))); + clearRedirectCookie(response); + return origin + redirectPath + "#access_token=" + jwt; + } + + 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(); + } + 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); + } + } + } + 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; + } + + private Optional resolveForwardedOrigin(HttpServletRequest request) { + String forwardedHostHeader = request.getHeader("X-Forwarded-Host"); + if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) { + return Optional.empty(); + } + String host = forwardedHostHeader.split(",")[0].trim(); + if (host.isEmpty()) { + return Optional.empty(); + } + + String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto"); + String proto = + (forwardedProtoHeader == null || forwardedProtoHeader.isBlank()) + ? request.getScheme() + : forwardedProtoHeader.split(",")[0].trim(); + + if (!host.contains(":")) { + String forwardedPort = request.getHeader("X-Forwarded-Port"); + if (forwardedPort != null + && !forwardedPort.isBlank() + && !isDefaultPort(proto, forwardedPort.trim())) { + host = host + ":" + forwardedPort.trim(); + } + } + return Optional.of(proto + "://" + host); + } + + private Optional resolveOriginFromReferer(HttpServletRequest request) { String referer = request.getHeader("Referer"); if (referer != null && !referer.isEmpty()) { try { @@ -189,14 +271,16 @@ public class CustomSaml2AuthenticationSuccessHandler && refererUrl.getPort() != 443) { origin += ":" + refererUrl.getPort(); } - return origin + "/auth/callback#access_token=" + jwt; + return Optional.of(origin); } catch (java.net.MalformedURLException e) { log.debug( "Malformed referer URL: {}, falling back to request-based origin", referer); } } + return Optional.empty(); + } - // Fall back to building from request host/port + private String buildOriginFromRequest(HttpServletRequest request) { String scheme = request.getScheme(); String serverName = request.getServerName(); int serverPort = request.getServerPort(); @@ -204,12 +288,34 @@ public class CustomSaml2AuthenticationSuccessHandler StringBuilder origin = new StringBuilder(); origin.append(scheme).append("://").append(serverName); - // Only add port if it's not the default port for the scheme - if ((!"http".equals(scheme) || serverPort != 80) - && (!"https".equals(scheme) || serverPort != 443)) { + if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80) + && (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) { origin.append(":").append(serverPort); } - return origin + "/auth/callback#access_token=" + jwt; + return origin.toString(); + } + + private boolean isDefaultPort(String scheme, String port) { + if (port == null) { + return true; + } + try { + int parsedPort = Integer.parseInt(port); + return ("http".equalsIgnoreCase(scheme) && parsedPort == 80) + || ("https".equalsIgnoreCase(scheme) && parsedPort == 443); + } catch (NumberFormatException e) { + return false; + } + } + + private void clearRedirectCookie(HttpServletResponse response) { + ResponseCookie cookie = + ResponseCookie.from(SPA_REDIRECT_COOKIE, "") + .path("/") + .sameSite("Lax") + .maxAge(0) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java index a47f45318..0fcd0f4c6 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java @@ -29,6 +29,8 @@ class JwtAuthenticationEntryPointTest { @Test void testCommence() throws IOException { String errorMessage = "Authentication failed"; + + when(request.getRequestURI()).thenReturn("/redact"); when(authException.getMessage()).thenReturn(errorMessage); jwtAuthenticationEntryPoint.commence(request, response, authException); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index db900aeea..43ee35e16 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -441,6 +441,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -487,6 +488,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -510,6 +512,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz", "integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/engines": "1.4.1", "@embedpdf/models": "1.4.1" @@ -593,6 +596,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz", "integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -609,6 +613,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz", "integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -626,6 +631,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz", "integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -662,6 +668,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz", "integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -696,6 +703,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz", "integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -732,6 +740,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz", "integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -807,6 +816,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz", "integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -962,6 +972,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1005,6 +1016,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2035,6 +2047,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz", "integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2085,6 +2098,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz", "integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2152,6 +2166,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz", "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.4", @@ -3835,6 +3850,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4158,6 +4174,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4168,6 +4185,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4228,6 +4246,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -4941,7 +4960,6 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", "license": "MIT", - "peer": true, "dependencies": { "@vue/shared": "3.5.22" } @@ -4951,7 +4969,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/shared": "3.5.22" @@ -4962,7 +4979,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/runtime-core": "3.5.22", @@ -4975,7 +4991,6 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.22", "@vue/shared": "3.5.22" @@ -5002,6 +5017,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5686,6 +5702,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6731,7 +6748,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7126,6 +7144,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7296,6 +7315,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8618,6 +8638,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9425,6 +9446,7 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -11201,6 +11223,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11480,6 +11503,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11852,6 +11876,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11861,6 +11886,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13531,6 +13557,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13832,6 +13859,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13914,6 +13942,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -14118,6 +14147,7 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14269,6 +14299,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14282,6 +14313,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 0fdd64d9e..5bb05a50b 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -1,5 +1,5 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; -import { useRequestHeaders } from '@app/hooks/useRequestHeaders'; +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import apiClient from '@app/services/apiClient'; export interface AppConfig { baseUrl?: string; @@ -36,52 +36,84 @@ interface AppConfigContextValue { refetch: () => Promise; } -// Create context -const AppConfigContext = createContext(undefined); +const AppConfigContext = createContext({ + config: null, + loading: true, + error: null, + refetch: async () => {}, +}); /** * Provider component that fetches and provides app configuration * Should be placed at the top level of the app, before any components that need config */ -export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const headers = useRequestHeaders(); + const [fetchCount, setFetchCount] = useState(0); + + const fetchConfig = async (force = false) => { + // Prevent duplicate fetches unless forced + if (!force && fetchCount > 0) { + console.debug('[AppConfig] Already fetched, skipping'); + return; + } - const fetchConfig = async () => { try { setLoading(true); setError(null); - const response = await fetch('/api/v1/config/app-config', { - headers, - }); + // apiClient automatically adds JWT header if available via interceptors + const response = await apiClient.get('/api/v1/config/app-config'); + const data = response.data; - if (!response.ok) { - throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`); + console.debug('[AppConfig] Config fetched successfully:', data); + setConfig(data); + setFetchCount(prev => prev + 1); + } catch (err: any) { + // On 401 (not authenticated), use default config with login enabled + // This allows the app to work even without authentication + if (err.response?.status === 401) { + console.debug('[AppConfig] 401 error - using default config (login enabled)'); + setConfig({ enableLogin: true }); + setLoading(false); + return; } - const data: AppConfig = await response.json(); - setConfig(data); - } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; setError(errorMessage); console.error('[AppConfig] Failed to fetch app config:', err); + // On error, assume login is enabled (safe default) + setConfig({ enableLogin: true }); } finally { setLoading(false); } }; useEffect(() => { + // Always try to fetch config to check if login is disabled + // The endpoint should be public and return proper JSON fetchConfig(); }, []); + // Listen for JWT availability (triggered on login/signup) + useEffect(() => { + const handleJwtAvailable = () => { + console.debug('[AppConfig] JWT available event - refetching config'); + // Force refetch with JWT + fetchConfig(true); + }; + + window.addEventListener('jwt-available', handleJwtAvailable); + return () => window.removeEventListener('jwt-available', handleJwtAvailable); + }, []); + const value: AppConfigContextValue = { config, loading, error, - refetch: fetchConfig, + refetch: () => fetchConfig(true), }; return ( @@ -104,4 +136,3 @@ export function useAppConfig(): AppConfigContextValue { return context; } - diff --git a/frontend/src/core/hooks/useEndpointConfig.ts b/frontend/src/core/hooks/useEndpointConfig.ts index 3c418aac2..83bb34b57 100644 --- a/frontend/src/core/hooks/useEndpointConfig.ts +++ b/frontend/src/core/hooks/useEndpointConfig.ts @@ -1,8 +1,13 @@ import { useState, useEffect } from 'react'; -import { useRequestHeaders } from '@app/hooks/useRequestHeaders'; +import apiClient from '@app/services/apiClient'; + +// Track globally fetched endpoint sets to prevent duplicate fetches across components +const globalFetchedSets = new Set(); +const globalEndpointCache: Record = {}; /** * Hook to check if a specific endpoint is enabled + * This wraps the context for single endpoint checks */ export function useEndpointEnabled(endpoint: string): { enabled: boolean | null; @@ -13,7 +18,6 @@ export function useEndpointEnabled(endpoint: string): { const [enabled, setEnabled] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const headers = useRequestHeaders(); const fetchEndpointStatus = async () => { if (!endpoint) { @@ -26,15 +30,8 @@ export function useEndpointEnabled(endpoint: string): { setLoading(true); setError(null); - const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, { - headers, - }); - - if (!response.ok) { - throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`); - } - - const isEnabled: boolean = await response.json(); + const response = await apiClient.get(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`); + const isEnabled = response.data; setEnabled(isEnabled); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; @@ -69,43 +66,101 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { const [endpointStatus, setEndpointStatus] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const headers = useRequestHeaders(); - const fetchAllEndpointStatuses = async () => { + const fetchAllEndpointStatuses = async (force = false) => { + const endpointsKey = [...endpoints].sort().join(','); + + // Skip if we already fetched these exact endpoints globally + if (!force && globalFetchedSets.has(endpointsKey)) { + console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache'); + const cachedStatus = endpoints.reduce((acc, endpoint) => { + if (endpoint in globalEndpointCache) { + acc[endpoint] = globalEndpointCache[endpoint]; + } + return acc; + }, {} as Record); + setEndpointStatus(cachedStatus); + setLoading(false); + return; + } if (!endpoints || endpoints.length === 0) { setEndpointStatus({}); setLoading(false); return; } + // Check if JWT exists - if not, optimistically enable all endpoints + const hasJwt = !!localStorage.getItem('stirling_jwt'); + if (!hasJwt) { + console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints'); + const optimisticStatus = endpoints.reduce((acc, endpoint) => { + acc[endpoint] = true; + return acc; + }, {} as Record); + setEndpointStatus(optimisticStatus); + setLoading(false); + return; + } + try { setLoading(true); setError(null); - // Use batch API for efficiency - const endpointsParam = endpoints.join(','); - - const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`, { - headers, - }); - - if (!response.ok) { - throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`); + // Check which endpoints we haven't fetched yet + const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache)); + if (newEndpoints.length === 0) { + console.debug('[useEndpointConfig] All endpoints already in global cache'); + const cachedStatus = endpoints.reduce((acc, endpoint) => { + acc[endpoint] = globalEndpointCache[endpoint]; + return acc; + }, {} as Record); + setEndpointStatus(cachedStatus); + globalFetchedSets.add(endpointsKey); + setLoading(false); + return; } - const statusMap: Record = await response.json(); - setEndpointStatus(statusMap); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - setError(errorMessage); - console.error('Failed to check multiple endpoints:', err); + // Use batch API for efficiency - only fetch new endpoints + const endpointsParam = newEndpoints.join(','); - // Fallback: assume all endpoints are disabled on error - const fallbackStatus = endpoints.reduce((acc, endpoint) => { - acc[endpoint] = false; + const response = await apiClient.get>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`); + const statusMap = response.data; + + // Update global cache with new results + Object.assign(globalEndpointCache, statusMap); + + // Get all requested endpoints from cache (including previously cached ones) + const fullStatus = endpoints.reduce((acc, endpoint) => { + acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache return acc; }, {} as Record); - setEndpointStatus(fallbackStatus); + + setEndpointStatus(fullStatus); + globalFetchedSets.add(endpointsKey); + } catch (err: any) { + // On 401 (auth error), use optimistic fallback instead of disabling + if (err.response?.status === 401) { + console.warn('[useEndpointConfig] 401 error - using optimistic fallback'); + const optimisticStatus = endpoints.reduce((acc, endpoint) => { + acc[endpoint] = true; + globalEndpointCache[endpoint] = true; // Cache the optimistic value + return acc; + }, {} as Record); + setEndpointStatus(optimisticStatus); + setLoading(false); + return; + } + + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; + setError(errorMessage); + console.error('[EndpointConfig] Failed to check multiple endpoints:', err); + + // Fallback: assume all endpoints are enabled on error (optimistic) + const optimisticStatus = endpoints.reduce((acc, endpoint) => { + acc[endpoint] = true; + return acc; + }, {} as Record); + setEndpointStatus(optimisticStatus); } finally { setLoading(false); } @@ -115,10 +170,24 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { fetchAllEndpointStatuses(); }, [endpoints.join(',')]); // Re-run when endpoints array changes + // Listen for JWT availability (triggered on login/signup) + useEffect(() => { + const handleJwtAvailable = () => { + console.debug('[useEndpointConfig] JWT available event - clearing cache for refetch with auth'); + // Clear the global cache to allow refetch with JWT + globalFetchedSets.clear(); + Object.keys(globalEndpointCache).forEach(key => delete globalEndpointCache[key]); + fetchAllEndpointStatuses(true); + }; + + window.addEventListener('jwt-available', handleJwtAvailable); + return () => window.removeEventListener('jwt-available', handleJwtAvailable); + }, [endpoints.join(',')]); + return { endpointStatus, loading, error, - refetch: fetchAllEndpointStatuses, + refetch: () => fetchAllEndpointStatuses(true), }; } diff --git a/frontend/src/core/hooks/useToolManagement.tsx b/frontend/src/core/hooks/useToolManagement.tsx index fcc543d27..3f5676824 100644 --- a/frontend/src/core/hooks/useToolManagement.tsx +++ b/frontend/src/core/hooks/useToolManagement.tsx @@ -24,10 +24,18 @@ export const useToolManagement = (): ToolManagementResult => { const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); const isToolAvailable = useCallback((toolKey: string): boolean => { + // Keep tools enabled during loading (optimistic UX) if (endpointsLoading) return true; + const tool = baseRegistry[toolKey as ToolId]; const endpoints = tool?.endpoints || []; - return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true); + + // Tools without endpoints are always available + if (endpoints.length === 0) return true; + + // Check if at least one endpoint is enabled + // If endpoint is not in status map, assume enabled (optimistic fallback) + return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false); }, [endpointsLoading, endpointStatus, baseRegistry]); const toolRegistry: Partial = useMemo(() => { diff --git a/frontend/src/proprietary/auth/UseSession.tsx b/frontend/src/proprietary/auth/UseSession.tsx index 0f3436b9a..2cb2868ec 100644 --- a/frontend/src/proprietary/auth/UseSession.tsx +++ b/frontend/src/proprietary/auth/UseSession.tsx @@ -95,23 +95,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { console.debug('[Auth] Initializing auth...'); - // First check if login is enabled - const configResponse = await fetch('/api/v1/config/app-config'); - if (configResponse.ok) { - const config = await configResponse.json(); - - // If login is disabled, skip authentication entirely - if (config.enableLogin === false) { - console.debug('[Auth] Login disabled - skipping authentication'); - if (mounted) { - setSession(null); - setLoading(false); - } - return; - } - } - - // Login is enabled, proceed with normal auth check + // Skip config check entirely - let the app handle login state + // The config will be fetched by useAppConfig when needed const { data, error } = await springAuth.getSession(); if (!mounted) return; diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 8567eb654..73c883b56 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -1,3 +1,5 @@ +import { BASE_PATH } from '@app/constants/app'; + /** * Spring Auth Client * @@ -7,6 +9,37 @@ * - No email confirmation flow (auto-confirmed on registration) */ +const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path'; +const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes +const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`; + +function normalizeRedirectPath(target?: string): string { + if (!target || typeof target !== 'string') { + return DEFAULT_REDIRECT_PATH; + } + + try { + const parsed = new URL(target, window.location.origin); + const path = parsed.pathname || '/'; + const query = parsed.search || ''; + return `${path}${query}`; + } catch { + const trimmed = target.trim(); + if (!trimmed) { + return DEFAULT_REDIRECT_PATH; + } + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + } +} + +function persistRedirectPath(path: string): void { + try { + document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`; + } catch (error) { + console.warn('[SpringAuth] Failed to persist OAuth redirect path', error); + } +} + // Auth types export interface User { id: string; @@ -85,20 +118,44 @@ class SpringAuthClient { } // Verify with backend + console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me'); const response = await fetch('/api/v1/auth/me', { headers: { 'Authorization': `Bearer ${token}`, }, }); + console.debug('[SpringAuth] /me response status:', response.status); + const contentType = response.headers.get('content-type'); + console.debug('[SpringAuth] /me content-type:', contentType); + if (!response.ok) { + // Log the error response for debugging + const errorBody = await response.text(); + console.error('[SpringAuth] getSession: /api/v1/auth/me failed', { + status: response.status, + statusText: response.statusText, + body: errorBody + }); + // Token invalid or expired - clear it localStorage.removeItem('stirling_jwt'); - console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')'); - return { data: { session: null }, error: null }; + console.warn('[SpringAuth] getSession: Cleared invalid JWT from localStorage'); + return { data: { session: null }, error: { message: `Auth failed: ${response.status}` } }; + } + + // Check if response is JSON before parsing + if (!contentType?.includes('application/json')) { + const text = await response.text(); + console.error('[SpringAuth] /me returned non-JSON:', { + contentType, + bodyPreview: text.substring(0, 200) + }); + throw new Error(`/api/v1/auth/me returned HTML instead of JSON`); } const data = await response.json(); + console.debug('[SpringAuth] /me response data:', data); // Create session object const session: Session = { @@ -151,6 +208,9 @@ class SpringAuthClient { localStorage.setItem('stirling_jwt', token); console.log('[SpringAuth] JWT stored in localStorage'); + // Dispatch custom event for other components to react to JWT availability + window.dispatchEvent(new CustomEvent('jwt-available')); + const session: Session = { user: data.user, access_token: token, @@ -220,6 +280,9 @@ class SpringAuthClient { options?: { redirectTo?: string; queryParams?: Record }; }): Promise<{ error: AuthError | null }> { try { + const redirectPath = normalizeRedirectPath(params.options?.redirectTo); + persistRedirectPath(redirectPath); + // Redirect to Spring OAuth2 endpoint (Vite will proxy to backend) const redirectUrl = `/oauth2/authorization/${params.provider}`; console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl); @@ -299,6 +362,9 @@ class SpringAuthClient { // Store new token localStorage.setItem('stirling_jwt', newToken); + // Dispatch custom event for other components to react to JWT availability + window.dispatchEvent(new CustomEvent('jwt-available')); + // Get updated user info const userResponse = await fetch('/api/v1/auth/me', { headers: { diff --git a/frontend/src/proprietary/routes/AuthCallback.tsx b/frontend/src/proprietary/routes/AuthCallback.tsx index 9c7a11ba7..0c7128368 100644 --- a/frontend/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/src/proprietary/routes/AuthCallback.tsx @@ -36,6 +36,9 @@ export default function AuthCallback() { localStorage.setItem('stirling_jwt', token); console.log('[AuthCallback] JWT stored in localStorage'); + // Dispatch custom event for other components to react to JWT availability + window.dispatchEvent(new CustomEvent('jwt-available')) + // Refresh session to load user info into state await refreshSession(); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9e3bb0f2a..2117b7bd1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -30,21 +30,24 @@ export default defineConfig(({ mode }) => { // tell vite to ignore watching `src-tauri` ignored: ['**/src-tauri/**'], }, - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, - }, - '/oauth2': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, - }, - '/login/oauth2': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, + '/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, + '/login/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, }, }, },