From e6c49d173744de95ae5ca4e76e5ffb30b8e4a0f4 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Fri, 31 Oct 2025 13:10:00 +0000 Subject: [PATCH] fixing oauth redirect issues # Conflicts: # app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java # Conflicts: # app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java --- .../configuration/SecurityConfiguration.java | 93 +++++++++---------- .../filter/JwtAuthenticationFilter.java | 26 ++++-- .../filter/UserAuthenticationFilter.java | 25 ++++- ...tomOAuth2AuthenticationSuccessHandler.java | 2 +- .../src/core/contexts/AppConfigContext.tsx | 47 +++------- frontend/src/core/hooks/useEndpointConfig.ts | 62 ++++--------- 6 files changed, 118 insertions(+), 137 deletions(-) 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 49f98570a..084bb7c58 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 @@ -71,6 +71,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; @@ -90,6 +91,7 @@ public class SecurityConfiguration { @Qualifier("loginEnabled") boolean loginEnabledValue, @Qualifier("runningProOrHigher") boolean runningProOrHigher, AppConfig appConfig, + ApplicationProperties applicationProperties, ApplicationProperties.Security securityProperties, UserAuthenticationFilter userAuthenticationFilter, JwtServiceInterface jwtService, @@ -106,6 +108,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; @@ -127,53 +130,46 @@ public class SecurityConfiguration { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); - // Set allowed origin patterns - if (appConfig.v2Enabled()) { - // Development mode - allow common development ports - cfg.setAllowedOriginPatterns( + // Read CORS allowed origins from settings + if (applicationProperties.getSystem() != null + && applicationProperties.getSystem().getCorsAllowedOrigins() != null + && !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) { + + List allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins(); + + cfg.setAllowedOrigins(allowedOrigins); + log.info("CORS configured with allowed origins from settings.yml: {}", allowedOrigins); + + // Set allowed methods explicitly + cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + + // Set allowed headers explicitly + cfg.setAllowedHeaders( List.of( - "http://localhost:3000", // Common React dev server - "http://localhost:5173", // Vite default port - "http://localhost:5174", // Vite alternate port - "http://localhost:8080", // Backend port - "http://localhost:*", // Any localhost port - "https://localhost:*" // HTTPS localhost - )); - log.info("CORS configured for development mode (v2 enabled)"); + "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")); + + // Allow credentials (cookies, authorization headers) + cfg.setAllowCredentials(true); + + // Set max age for preflight cache + cfg.setMaxAge(3600L); } else { - // Production mode - be more restrictive - // You should configure production domains here - cfg.setAllowedOriginPatterns( - List.of( - "http://localhost:*", // Still allow localhost for local deployments - "https://localhost:*")); - log.info("CORS configured for production mode"); + // No CORS origins configured - CORS is disabled (secure by default) + // In production, frontend and backend are served from same origin (no CORS needed) + log.info( + "CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)"); } - // Set allowed methods explicitly - 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")); - - // 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; @@ -181,7 +177,7 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - // Enable CORS with our custom configuration + // Enable CORS with custom configuration http.cors(cors -> cors.configurationSource(corsConfigurationSource())); if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { @@ -194,11 +190,8 @@ public class SecurityConfiguration { http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore( - rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); - - if (v2Enabled) { - http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class); - } + rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class); if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = @@ -337,6 +330,8 @@ public class SecurityConfiguration { "/api/v1/auth/login") || trimmedUri.startsWith( "/api/v1/auth/refresh") + || trimmedUri.startsWith( + "/api/v1/auth/logout") || trimmedUri.startsWith( "/api/v1/proprietary/ui-data/account") || trimmedUri.startsWith("/v1/api-docs") 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 e21b87182..b4523f060 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 @@ -135,18 +135,24 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { } 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 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") - || requestURI.startsWith( - contextPath + "/api/v1/proprietary/ui-data/account") - || requestURI.startsWith(contextPath + "/api/v1/config"); + trimmedUri.startsWith("/login") + || trimmedUri.startsWith("/signup") + || trimmedUri.startsWith("/auth/") + || trimmedUri.startsWith("/oauth2") + || trimmedUri.startsWith("/api/v1/auth/login") + || trimmedUri.startsWith("/api/v1/auth/register") + || trimmedUri.startsWith("/api/v1/auth/refresh") + || trimmedUri.startsWith("/api/v1/auth/logout") + || trimmedUri.startsWith("/api/v1/proprietary/ui-data/account") + || trimmedUri.startsWith("/api/v1/config"); return isPublicAuthEndpoint; } 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..234a029a3 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 @@ -105,11 +105,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 +206,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"), 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..1137602b6 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 @@ -186,6 +186,6 @@ public class CustomOAuth2AuthenticationSuccessHandler origin.append(":").append(serverPort); } - return origin.toString() + "/auth/callback#access_token=" + jwt; + return origin + "/auth/callback#access_token=" + jwt; } } diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 6890c2e12..5bb05a50b 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -1,10 +1,5 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; - -// Helper to get JWT from localStorage for Authorization header -function getAuthHeaders(): HeadersInit { - const token = localStorage.getItem('stirling_jwt'); - return token ? { 'Authorization': `Bearer ${token}` } : {}; -} +import apiClient from '@app/services/apiClient'; export interface AppConfig { baseUrl?: string; @@ -65,41 +60,27 @@ export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children return; } - // Don't fetch config if we're on the login page and don't have JWT - const isLoginPage = window.location.pathname.includes('/login'); - const hasJwt = !!localStorage.getItem('stirling_jwt'); - - if (isLoginPage && !hasJwt) { - console.debug('[AppConfig] On login page without JWT - using default config'); - setConfig({ enableLogin: true }); - setLoading(false); - return; - } - try { setLoading(true); setError(null); - const response = await fetch('/api/v1/config/app-config', { - headers: getAuthHeaders(), - }); + // 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) { - // On 401 (not authenticated), use default config with login enabled - if (response.status === 401) { - console.debug('[AppConfig] 401 error - using default config (login enabled)'); - setConfig({ enableLogin: true }); - setLoading(false); - return; - } - throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`); - } - - const data: AppConfig = await response.json(); console.debug('[AppConfig] Config fetched successfully:', data); setConfig(data); setFetchCount(prev => prev + 1); - } catch (err) { + } 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 errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; setError(errorMessage); console.error('[AppConfig] Failed to fetch app config:', err); diff --git a/frontend/src/core/hooks/useEndpointConfig.ts b/frontend/src/core/hooks/useEndpointConfig.ts index e5f2320a6..83bb34b57 100644 --- a/frontend/src/core/hooks/useEndpointConfig.ts +++ b/frontend/src/core/hooks/useEndpointConfig.ts @@ -1,16 +1,10 @@ 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 = {}; -// Helper to get JWT from localStorage for Authorization header -function getAuthHeaders(): HeadersInit { - const token = localStorage.getItem('stirling_jwt'); - return token ? { 'Authorization': `Bearer ${token}` } : {}; -} - /** * Hook to check if a specific endpoint is enabled * This wraps the context for single endpoint checks @@ -24,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) { @@ -37,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: getAuthHeaders(), - }); - - 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'; @@ -80,8 +66,6 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { const [endpointStatus, setEndpointStatus] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [lastFetchedEndpoints, setLastFetchedEndpoints] = useState(''); - const _headers = useRequestHeaders(); const fetchAllEndpointStatuses = async (force = false) => { const endpointsKey = [...endpoints].sort().join(','); @@ -139,27 +123,8 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { // Use batch API for efficiency - only fetch new endpoints const endpointsParam = newEndpoints.join(','); - const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`, { - headers: getAuthHeaders(), - }); - - if (!response.ok) { - // On 401 (auth error), use optimistic fallback instead of disabling - if (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; - } - throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`); - } - - const statusMap: Record = await response.json(); + 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); @@ -172,8 +137,20 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { setEndpointStatus(fullStatus); globalFetchedSets.add(endpointsKey); - setLastFetchedEndpoints(endpointsKey); - } catch (err) { + } 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); @@ -200,7 +177,6 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { // Clear the global cache to allow refetch with JWT globalFetchedSets.clear(); Object.keys(globalEndpointCache).forEach(key => delete globalEndpointCache[key]); - setLastFetchedEndpoints(''); fetchAllEndpointStatuses(true); };