diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/InviteApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/InviteApi.java new file mode 100644 index 000000000..144d267e9 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/InviteApi.java @@ -0,0 +1,37 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Invite management controllers. + * Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/invite") +@Tag( + name = "Invite", + description = + """ + Invite-link generation and acceptance endpoints for onboarding new users. + + Provides the ability to issue invitation tokens, send optional email invites, + validate and accept invite links, and manage pending invitations for teams. + + Typical use cases include: + • Admin workflows for issuing time-limited invitations to external users + • Self-service invite acceptance and team assignment + • License limit enforcement when provisioning new accounts + + Target users: administrators and automation scripts orchestrating user onboarding. + """) +public @interface InviteApi {} 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 76c8dec30..bd6acc1b9 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 @@ -129,61 +129,53 @@ public class SecurityConfiguration { @Bean public CorsConfigurationSource corsConfigurationSource() { - // Read CORS allowed origins from settings - if (applicationProperties.getSystem() != null - && applicationProperties.getSystem().getCorsAllowedOrigins() != null - && !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) { + List configuredOrigins = null; + if (applicationProperties.getSystem() != null) { + configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins(); + } - List allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins(); - - CorsConfiguration cfg = new CorsConfiguration(); - - // Use setAllowedOriginPatterns for better wildcard and port support - cfg.setAllowedOriginPatterns(allowedOrigins); + CorsConfiguration cfg = new CorsConfiguration(); + if (configuredOrigins != null && !configuredOrigins.isEmpty()) { + cfg.setAllowedOriginPatterns(configuredOrigins); 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; + configuredOrigins); } else { - // No CORS origins configured - return null to disable CORS processing entirely - // This avoids empty CORS policy that unexpectedly rejects preflights + // Default to allowing all origins when nothing is configured + cfg.setAllowedOriginPatterns(List.of("*")); log.info( - "CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)"); - return null; + "No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins."); } + + // Explicitly configure supported HTTP methods (include OPTIONS for preflight) + cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + + cfg.setAllowedHeaders( + List.of( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "X-API-KEY", + "X-CSRF-TOKEN", + "X-XSRF-TOKEN")); + + cfg.setExposedHeaders( + List.of( + "WWW-Authenticate", + "X-Total-Count", + "X-Page-Number", + "X-Page-Size", + "Content-Disposition", + "Content-Type")); + + cfg.setAllowCredentials(true); + cfg.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", cfg); + return source; } @Bean diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java index 5ce637259..ceddc7a58 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java @@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.annotations.api.UserApi; +import stirling.software.common.annotations.api.InviteApi; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.model.Team; @@ -26,11 +26,9 @@ import stirling.software.proprietary.security.service.EmailService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; -@UserApi +@InviteApi @Slf4j @RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/invite") public class InviteLinkController { private final InviteTokenRepository inviteTokenRepository; diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 04fb55957..390ea28e5 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -107,7 +107,7 @@ class SpringAuthClient { for (const cookie of cookies) { const [name, value] = cookie.trim().split('='); if (name === 'XSRF-TOKEN') { - return value; + return decodeURIComponent(value); } } return null; @@ -278,7 +278,7 @@ class SpringAuthClient { try { const response = await apiClient.post('/api/v1/auth/logout', null, { headers: { - 'X-CSRF-TOKEN': this.getCsrfToken() || '', + 'X-XSRF-TOKEN': this.getCsrfToken() || '', }, withCredentials: true, }); @@ -311,7 +311,7 @@ class SpringAuthClient { try { const response = await apiClient.post('/api/v1/auth/refresh', null, { headers: { - 'X-CSRF-TOKEN': this.getCsrfToken() || '', + 'X-XSRF-TOKEN': this.getCsrfToken() || '', }, withCredentials: true, }); diff --git a/frontend/src/proprietary/services/apiClientSetup.ts b/frontend/src/proprietary/services/apiClientSetup.ts index be51b165c..9b1ed75bd 100644 --- a/frontend/src/proprietary/services/apiClientSetup.ts +++ b/frontend/src/proprietary/services/apiClientSetup.ts @@ -9,17 +9,38 @@ function getJwtTokenFromStorage(): string | null { } } +function getXsrfToken(): string | null { + try { + const cookies = document.cookie.split(';'); + for (const cookie of cookies) { + const [name, value] = cookie.trim().split('='); + if (name === 'XSRF-TOKEN') { + return decodeURIComponent(value); + } + } + return null; + } catch (error) { + console.error('[API Client] Failed to read XSRF token from cookies:', error); + return null; + } +} + export function setupApiInterceptors(client: AxiosInstance): void { // Install request interceptor to add JWT token client.interceptors.request.use( (config) => { const jwtToken = getJwtTokenFromStorage(); + const xsrfToken = getXsrfToken(); if (jwtToken && !config.headers.Authorization) { config.headers.Authorization = `Bearer ${jwtToken}`; console.debug('[API Client] Added JWT token from localStorage to Authorization header'); } + if (xsrfToken && !config.headers['X-XSRF-TOKEN']) { + config.headers['X-XSRF-TOKEN'] = xsrfToken; + } + return config; }, (error) => {