mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
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] <support@github.com> Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ludy <Ludy87@users.noreply.github.com> Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Co-authored-by: Ethan <ethan@MacBook-Pro.local> 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 <connor@stirlingpdf.com>
This commit is contained in:
parent
9440e99227
commit
f5c67a3239
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String> 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))
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
@ -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/")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<String> 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<String> 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<String> 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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<String> 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<String> 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<String> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
42
frontend/package-lock.json
generated
42
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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<void>;
|
||||
}
|
||||
|
||||
// Create context
|
||||
const AppConfigContext = createContext<AppConfigContextValue | undefined>(undefined);
|
||||
const AppConfigContext = createContext<AppConfigContextValue | undefined>({
|
||||
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<AppConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<AppConfig>('/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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<string>();
|
||||
const globalEndpointCache: Record<string, boolean> = {};
|
||||
|
||||
/**
|
||||
* 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<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<boolean>(`/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<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<string, boolean>);
|
||||
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<string, boolean>);
|
||||
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<string, boolean>);
|
||||
setEndpointStatus(cachedStatus);
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusMap: Record<string, boolean> = 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<Record<string, boolean>>(`/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<string, boolean>);
|
||||
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<string, boolean>);
|
||||
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<string, boolean>);
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<ToolRegistry> = useMemo(() => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<string, any> };
|
||||
}): 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: {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user