diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 8f09c7568c..0849d70059 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -43,6 +43,7 @@ dependencies { api "org.springframework.security:spring-security-core:$springSecuritySamlVersion" api "org.springframework.security:spring-security-web:$springSecuritySamlVersion" api "org.springframework.security:spring-security-config:$springSecuritySamlVersion" + api("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.7") api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion" api 'org.springframework.boot:spring-boot-starter-jetty' api 'org.springframework.boot:spring-boot-starter-security' diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java index 028cee6850..75bd6045e0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java @@ -59,6 +59,9 @@ public class CustomAuthenticationSuccessHandler authentication, Map.of("authType", AuthenticationType.WEB)); log.debug("JWT generated for user: {}", userName); + // Set JWT as HttpOnly cookie for security + jwtService.setJwtCookie(response, jwt, request.getContextPath()); + getRedirectStrategy().sendRedirect(request, response, "/"); } else { // Get the saved request 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 b7e51fe2cd..8f33851eda 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 @@ -68,7 +68,7 @@ public class SecurityConfiguration { private final boolean loginEnabledValue; private final boolean runningProOrHigher; - private final ApplicationProperties.Security securityProperties; + private final ApplicationProperties applicationProperties; private final AppConfig appConfig; private final UserAuthenticationFilter userAuthenticationFilter; private final JwtServiceInterface jwtService; @@ -88,7 +88,7 @@ public class SecurityConfiguration { @Qualifier("loginEnabled") boolean loginEnabledValue, @Qualifier("runningProOrHigher") boolean runningProOrHigher, AppConfig appConfig, - ApplicationProperties.Security securityProperties, + ApplicationProperties applicationProperties, UserAuthenticationFilter userAuthenticationFilter, JwtServiceInterface jwtService, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, @@ -105,7 +105,7 @@ public class SecurityConfiguration { this.loginEnabledValue = loginEnabledValue; this.runningProOrHigher = runningProOrHigher; this.appConfig = appConfig; - this.securityProperties = securityProperties; + this.applicationProperties = applicationProperties; this.userAuthenticationFilter = userAuthenticationFilter; this.jwtService = jwtService; this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; @@ -125,24 +125,19 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { + if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) { http.csrf(CsrfConfigurer::disable); } if (loginEnabledValue) { - boolean v2Enabled = appConfig.v2Enabled(); - http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore( rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class); + .addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class) + .addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class); - if (v2Enabled) { - http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class); - } - - if (!securityProperties.getCsrfDisabled()) { + if (!applicationProperties.getSecurity().getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = CookieCsrfTokenRepository.withHttpOnlyFalse(); CsrfTokenRequestAttributeHandler requestHandler = @@ -185,21 +180,12 @@ public class SecurityConfiguration { } http.sessionManagement( - sessionManagement -> { - if (v2Enabled) { - sessionManagement.sessionCreationPolicy( - SessionCreationPolicy.STATELESS); - } else { - sessionManagement - .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) - .maximumSessions(10) - .maxSessionsPreventsLogin(false) - .sessionRegistry(sessionRegistry) - .expiredUrl("/login?logout=true"); - } - }); - http.authenticationProvider(daoAuthenticationProvider()); - http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache())); + sessionManagement -> + sessionManagement.sessionCreationPolicy( + SessionCreationPolicy.STATELESS)) + .authenticationProvider(daoAuthenticationProvider()) + .requestCache( + requestCache -> requestCache.requestCache(new NullRequestCache())); http.logout( logout -> logout.logoutRequestMatcher( @@ -207,7 +193,9 @@ public class SecurityConfiguration { .matcher("/logout")) .logoutSuccessHandler( new CustomLogoutSuccessHandler( - securityProperties, appConfig, jwtService)) + applicationProperties.getSecurity(), + appConfig, + jwtService)) .clearAuthentication(true) .invalidateHttpSession(true) .deleteCookies("JSESSIONID", "remember-me", "stirling_jwt")); @@ -275,7 +263,7 @@ public class SecurityConfiguration { .anyRequest() .authenticated()); // Handle User/Password Logins - if (securityProperties.isUserPass()) { + if (applicationProperties.getSecurity().isUserPass()) { http.formLogin( formLogin -> formLogin @@ -291,45 +279,40 @@ public class SecurityConfiguration { .permitAll()); } // Handle OAUTH2 Logins - if (securityProperties.isOauth2Active()) { + if (applicationProperties.getSecurity().isOauth2Active()) { http.oauth2Login( - oauth2 -> { - // v1: Use /oauth2 as login page for Thymeleaf templates - if (!v2Enabled) { - oauth2.loginPage("/oauth2"); - } - - // v2: Don't set loginPage, let default OAuth2 flow handle it - oauth2 - /* - This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database. - If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser' - is set as true, else login fails with an error message advising the same. - */ - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getOauth2(), - userService, - jwtService)) - .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) - // Add existing Authorities from the database - .userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - securityProperties - .getOauth2(), - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll(); - }); + oauth2 -> + oauth2 + /* + This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database. + If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser' + is set as true, else login fails with an error message advising the same. + */ + .successHandler( + new CustomOAuth2AuthenticationSuccessHandler( + applicationProperties, + loginAttemptService, + userService, + jwtService)) + .failureHandler( + new CustomOAuth2AuthenticationFailureHandler()) + // Add existing Authorities from the database + .userInfoEndpoint( + userInfoEndpoint -> + userInfoEndpoint + .oidcUserService( + new CustomOAuth2UserService( + applicationProperties + .getSecurity() + .getOauth2(), + userService, + loginAttemptService)) + .userAuthoritiesMapper( + oAuth2userAuthoritiesMapper)) + .permitAll()); } // Handle SAML - if (securityProperties.isSaml2Active() && runningProOrHigher) { + if (applicationProperties.getSecurity().isSaml2Active() && runningProOrHigher) { OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider(); authenticationProvider.setResponseAuthenticationConverter( @@ -345,8 +328,8 @@ public class SecurityConfiguration { new ProviderManager(authenticationProvider)) .successHandler( new CustomSaml2AuthenticationSuccessHandler( + applicationProperties, loginAttemptService, - securityProperties.getSaml2(), userService, jwtService)) .failureHandler( @@ -391,6 +374,6 @@ public class SecurityConfiguration { userService, userDetailsService, jwtAuthenticationEntryPoint, - securityProperties); + applicationProperties.getSecurity()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 67c120a922..c4e5cb5d0b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.security.controller.api; import java.util.HashMap; import java.util.Map; +import jakarta.servlet.http.Cookie; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -97,7 +98,8 @@ public class AuthController { String token = jwtService.generateToken(user.getUsername(), claims); // Generate refresh token for token rotation - String refreshToken = refreshTokenService.generateRefreshToken(user.getId(), servletRequest); + String refreshToken = + refreshTokenService.generateRefreshToken(user.getId(), servletRequest); // Set JWT as HttpOnly cookie for security setJwtCookie(response, token); @@ -173,7 +175,10 @@ public class AuthController { if (authentication != null && authentication.getPrincipal() instanceof User user) { // Revoke all refresh tokens for this user int revokedCount = refreshTokenService.revokeAllTokensForUser(user.getId()); - log.info("Revoked {} refresh token(s) for user: {}", revokedCount, user.getUsername()); + log.info( + "Revoked {} refresh token(s) for user: {}", + revokedCount, + user.getUsername()); } // Clear cookies @@ -194,12 +199,12 @@ public class AuthController { } /** - * Refresh token endpoint - validates refresh token and issues new access token + * Refresh token endpoint - validates refresh token and issues new access token. * Implements token rotation for security: revokes old refresh token and issues new one * - * @param request HTTP request containing refresh token cookie - * @param response HTTP response to set new cookies - * @return New token information + * @param request HTTP request + * @param response HTTP response + * @return the refreshed token */ @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/refresh") @@ -214,7 +219,6 @@ public class AuthController { .body(Map.of("error", "No refresh token found")); } - // Validate refresh token var refreshTokenOpt = refreshTokenService.validateRefreshToken(refreshToken); if (refreshTokenOpt.isEmpty()) { @@ -226,8 +230,8 @@ public class AuthController { var refreshTokenEntity = refreshTokenOpt.get(); Long userId = refreshTokenEntity.getUserId(); - // Load user User user = userService.findById(userId).orElse(null); + if (user == null) { log.warn("Token refresh failed: user not found for ID: {}", userId); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) @@ -277,7 +281,7 @@ public class AuthController { return null; } - for (jakarta.servlet.http.Cookie cookie : request.getCookies()) { + for (Cookie cookie : request.getCookies()) { if ("stirling_refresh_token".equals(cookie.getName())) { return cookie.getValue(); } @@ -287,20 +291,14 @@ public class AuthController { } /** - * Sets JWT as an HttpOnly cookie for security - * Prevents XSS attacks by making token inaccessible to JavaScript + * Sets JWT as an HttpOnly cookie for security Prevents XSS attacks by making token inaccessible + * to JavaScript * * @param response HTTP response to set cookie * @param jwt JWT token to store */ private void setJwtCookie(HttpServletResponse response, String jwt) { - jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie("stirling_jwt", jwt); - cookie.setHttpOnly(true); // Prevent JavaScript access (XSS protection) - cookie.setSecure(true); // Only send over HTTPS (set to false for local dev if needed) - cookie.setPath("/"); // Cookie available for entire app - cookie.setMaxAge(3600); // 1 hour (matches JWT expiration) - cookie.setAttribute("SameSite", "Lax"); // CSRF protection - response.addCookie(cookie); + jwtService.setJwtCookie(response, jwt, ""); } /** @@ -310,14 +308,7 @@ public class AuthController { * @param refreshToken Refresh token to store */ private void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) { - jakarta.servlet.http.Cookie cookie = - new jakarta.servlet.http.Cookie("stirling_refresh_token", refreshToken); - cookie.setHttpOnly(true); // Prevent JavaScript access - cookie.setSecure(true); // Only send over HTTPS - cookie.setPath("/"); // Cookie available for entire app - cookie.setMaxAge(7 * 24 * 3600); // 7 days (matches refresh token expiration) - cookie.setAttribute("SameSite", "Lax"); // CSRF protection - response.addCookie(cookie); + jwtService.setRefreshTokenCookie(response, refreshToken, "", 7 * 24 * 3600); } /** @@ -326,22 +317,8 @@ public class AuthController { * @param response HTTP response */ private void clearAuthCookies(HttpServletResponse response) { - // Clear access token cookie - jakarta.servlet.http.Cookie jwtCookie = new jakarta.servlet.http.Cookie("stirling_jwt", ""); - jwtCookie.setHttpOnly(true); - jwtCookie.setSecure(true); - jwtCookie.setPath("/"); - jwtCookie.setMaxAge(0); // Delete immediately - response.addCookie(jwtCookie); - - // Clear refresh token cookie - jakarta.servlet.http.Cookie refreshCookie = - new jakarta.servlet.http.Cookie("stirling_refresh_token", ""); - refreshCookie.setHttpOnly(true); - refreshCookie.setSecure(true); - refreshCookie.setPath("/"); - refreshCookie.setMaxAge(0); // Delete immediately - response.addCookie(refreshCookie); + jwtService.removeJwtCookie(response, ""); + jwtService.removeRefreshTokenCookie(response, ""); } /** @@ -366,6 +343,21 @@ public class AuthController { return userMap; } + /** + * Get security configuration (including secureCookie flag) + * + * @return Security configuration + */ + @GetMapping("/config") + public ResponseEntity> getAuthConfig() { + Map config = new HashMap<>(); + config.put("secureCookie", jwtService.isSecureCookie()); + config.put("jwtEnabled", jwtService.isJwtEnabled()); + + log.debug("Auth config requested: secureCookie={}", jwtService.isSecureCookie()); + return ResponseEntity.ok(config); + } + // =========================== // Request/Response DTOs // =========================== 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 d6a34264f2..4aeb998d37 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 @@ -10,12 +10,17 @@ import java.sql.SQLException; import java.util.Map; import java.util.Optional; +import java.time.Instant; +import java.util.HashMap; + import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.web.filter.OncePerRequestFilter; @@ -128,7 +133,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { log.debug("JWT token username: {}", tokenUsername); try { - authenticate(request, claims); + authenticate(request, jwtToken, claims); log.debug("Authentication successful for user: {}", tokenUsername); } catch (SQLException | UnsupportedProviderException e) { log.error("Error processing user authentication for user: {}", tokenUsername, e); @@ -183,7 +188,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { return true; } - private void authenticate(HttpServletRequest request, Map claims) + private void authenticate( + HttpServletRequest request, String jwtToken, Map claims) throws SQLException, UnsupportedProviderException { String username = claims.get("sub").toString(); @@ -192,11 +198,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (userDetails != null) { - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken( - userDetails, null, userDetails.getAuthorities()); + // Create a Jwt object from the token string and claims + Jwt jwt = createJwtFromClaims(jwtToken, claims); - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + JwtAuthenticationToken authToken = + new JwtAuthenticationToken(jwt, userDetails.getAuthorities()); + + authToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } else { throw new UsernameNotFoundException("User not found: " + username); @@ -204,6 +213,37 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { } } + /** + * Creates a Spring Security Jwt object from the token string and claims This is needed for + * JwtAuthenticationToken + * + * @param tokenValue The JWT token string + * @param claims The extracted claims from the token + * @return A Jwt object + */ + private Jwt createJwtFromClaims(String tokenValue, Map claims) { + // Extract standard claims + Instant issuedAt = + claims.containsKey("iat") + ? Instant.ofEpochSecond(((Number) claims.get("iat")).longValue()) + : Instant.now(); + + Instant expiresAt = + claims.containsKey("exp") + ? Instant.ofEpochSecond(((Number) claims.get("exp")).longValue()) + : Instant.now().plusSeconds(3600); + + // Extract headers (kid if present) + Map headers = new HashMap<>(); + if (claims.containsKey("kid")) { + headers.put("kid", claims.get("kid")); + } + headers.put("alg", "RS256"); + + // Create and return the Jwt object + return new Jwt(tokenValue, issuedAt, expiresAt, headers, claims); + } + private void processUserAuthenticationType(Map claims, String username) throws SQLException, UnsupportedProviderException { AuthenticationType authenticationType = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/RefreshToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/RefreshToken.java index bc4fb93a89..405c434dc4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/RefreshToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/RefreshToken.java @@ -19,9 +19,9 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * Refresh Token entity for implementing secure token rotation - * Refresh tokens are long-lived tokens that can be used to obtain new access tokens - * This prevents stolen access tokens from being kept alive indefinitely + * Refresh Token entity for implementing secure token rotation Refresh tokens are long-lived tokens + * that can be used to obtain new access tokens This prevents stolen access tokens from being kept + * alive indefinitely */ @Entity @Table( @@ -42,33 +42,26 @@ public class RefreshToken { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - /** User ID this refresh token belongs to */ @Column(name = "user_id", nullable = false) private Long userId; - /** SHA-256 hash of the refresh token (never store tokens in plaintext) */ @Column(name = "token_hash", nullable = false, unique = true, length = 64) private String tokenHash; - /** When this refresh token expires */ @Column(name = "expires_at", nullable = false) private LocalDateTime expiresAt; - /** When this refresh token was created */ @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; - /** Whether this refresh token has been revoked (for logout/security events) */ @Column(name = "revoked", nullable = false) @Builder.Default private boolean revoked = false; - /** IP address from which the token was issued (optional, for audit trail) */ @Column(name = "issued_ip", length = 45) private String issuedIp; - /** User agent from which the token was issued (optional, for audit trail) */ @Column(name = "user_agent", length = 255) private String userAgent; 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 588cde2537..5849390226 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 @@ -36,11 +36,10 @@ import stirling.software.proprietary.security.service.UserService; public class CustomOAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + private final ApplicationProperties applicationProperties; private final LoginAttemptService loginAttemptService; - private final ApplicationProperties.Security.OAUTH2 oauth2Properties; private final UserService userService; private final JwtServiceInterface jwtService; - private final ApplicationProperties applicationProperties; @Override public void onAuthenticationSuccess( @@ -69,6 +68,9 @@ public class CustomOAuth2AuthenticationSuccessHandler // Redirect to the original destination super.onAuthenticationSuccess(request, response, authentication); } else { + ApplicationProperties.Security.OAUTH2 oauth2Properties = + applicationProperties.getSecurity().getOauth2(); + if (loginAttemptService.isBlocked(username)) { if (session != null) { session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST"); @@ -118,7 +120,7 @@ public class CustomOAuth2AuthenticationSuccessHandler authentication, Map.of("authType", AuthenticationType.OAUTH2)); // Set JWT as HttpOnly cookie for security - setJwtCookie(response, jwt, contextPath); + jwtService.setJwtCookie(response, jwt, contextPath); // Build context-aware redirect URL (without JWT in URL) String redirectUrl = buildContextAwareRedirectUrl(request, contextPath); @@ -147,24 +149,6 @@ public class CustomOAuth2AuthenticationSuccessHandler return null; } - /** - * Sets JWT as an HttpOnly cookie for security - * Prevents XSS attacks by making token inaccessible to JavaScript - * - * @param response HTTP response to set cookie - * @param jwt JWT token to store - * @param contextPath Application context path for cookie path - */ - private void setJwtCookie(HttpServletResponse response, String jwt, String contextPath) { - jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie("stirling_jwt", jwt); - cookie.setHttpOnly(true); // Prevent JavaScript access (XSS protection) - cookie.setSecure(true); // Only send over HTTPS (set to false for local dev if needed) - cookie.setPath(contextPath.isEmpty() ? "/" : contextPath); // Cookie available for entire app - cookie.setMaxAge(3600); // 1 hour (matches JWT expiration) - cookie.setAttribute("SameSite", "Lax"); // CSRF protection - response.addCookie(cookie); - } - /** * Validates if the origin is in the CORS whitelist * @@ -172,6 +156,7 @@ public class CustomOAuth2AuthenticationSuccessHandler * @return true if origin is whitelisted or no whitelist configured */ private boolean isOriginWhitelisted(String origin) { + ApplicationProperties applicationProperties = new ApplicationProperties(); if (applicationProperties.getSystem() == null || applicationProperties.getSystem().getCorsAllowedOrigins() == null || applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) { @@ -183,8 +168,8 @@ public class CustomOAuth2AuthenticationSuccessHandler } /** - * Builds a context-aware redirect URL based on the request's origin - * Validates Referer against CORS whitelist to prevent token leakage to third parties + * Builds a context-aware redirect URL based on the request's origin Validates Referer against + * CORS whitelist to prevent token leakage to third parties * * @param request The HTTP request * @param contextPath The application context path @@ -206,9 +191,7 @@ public class CustomOAuth2AuthenticationSuccessHandler // SECURITY: Only trust Referer if it's in the CORS whitelist // This prevents redirecting with JWT to untrusted domains (e.g., IdP domain) if (isOriginWhitelisted(origin)) { - log.debug( - "Using whitelisted Referer origin for redirect: {}", - origin); + log.debug("Using whitelisted Referer origin for redirect: {}", origin); return origin + "/auth/callback"; } else { log.warn( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 47dc9d7afd..8d3092ff23 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -33,11 +33,10 @@ import stirling.software.proprietary.security.service.UserService; public class CustomSaml2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + private final ApplicationProperties applicationProperties; private LoginAttemptService loginAttemptService; - private ApplicationProperties.Security.SAML2 saml2Properties; private UserService userService; private final JwtServiceInterface jwtService; - private final ApplicationProperties applicationProperties; @Override public void onAuthenticationSuccess( @@ -73,7 +72,7 @@ public class CustomSaml2AuthenticationSuccessHandler } else { log.debug( "Processing SAML2 authentication with autoCreateUser: {}", - saml2Properties.getAutoCreateUser()); + applicationProperties.getSecurity().getSaml2().getAutoCreateUser()); if (loginAttemptService.isBlocked(username)) { log.debug("User {} is blocked due to too many login attempts", username); @@ -101,7 +100,7 @@ public class CustomSaml2AuthenticationSuccessHandler if (userExists && hasPassword && (!isSSOUser || !isSAML2User) - && saml2Properties.getAutoCreateUser()) { + && applicationProperties.getSecurity().getSaml2().getAutoCreateUser()) { log.debug( "User {} exists with password but is not SSO user, redirecting to logout", username); @@ -111,7 +110,11 @@ public class CustomSaml2AuthenticationSuccessHandler } try { - if (!userExists || saml2Properties.getBlockRegistration()) { + if (!userExists + || applicationProperties + .getSecurity() + .getSaml2() + .getBlockRegistration()) { log.debug("Registration blocked for new user: {}", username); response.sendRedirect( contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); @@ -132,7 +135,7 @@ public class CustomSaml2AuthenticationSuccessHandler username, ssoProviderId, ssoProvider, - saml2Properties.getAutoCreateUser(), + applicationProperties.getSecurity().getSaml2().getAutoCreateUser(), SAML2); log.debug("Successfully processed authentication for user: {}", username); @@ -144,7 +147,7 @@ public class CustomSaml2AuthenticationSuccessHandler Map.of("authType", AuthenticationType.SAML2)); // Set JWT as HttpOnly cookie for security - setJwtCookie(response, jwt, contextPath); + jwtService.setJwtCookie(response, jwt, contextPath); // Build context-aware redirect URL (without JWT in URL) String redirectUrl = buildContextAwareRedirectUrl(request, contextPath); @@ -167,24 +170,6 @@ public class CustomSaml2AuthenticationSuccessHandler } } - /** - * Sets JWT as an HttpOnly cookie for security - * Prevents XSS attacks by making token inaccessible to JavaScript - * - * @param response HTTP response to set cookie - * @param jwt JWT token to store - * @param contextPath Application context path for cookie path - */ - private void setJwtCookie(HttpServletResponse response, String jwt, String contextPath) { - jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie("stirling_jwt", jwt); - cookie.setHttpOnly(true); // Prevent JavaScript access (XSS protection) - cookie.setSecure(true); // Only send over HTTPS (set to false for local dev if needed) - cookie.setPath(contextPath.isEmpty() ? "/" : contextPath); // Cookie available for entire app - cookie.setMaxAge(3600); // 1 hour (matches JWT expiration) - cookie.setAttribute("SameSite", "Lax"); // CSRF protection - response.addCookie(cookie); - } - /** * Validates if the origin is in the CORS whitelist * @@ -203,8 +188,8 @@ public class CustomSaml2AuthenticationSuccessHandler } /** - * Builds a context-aware redirect URL based on the request's origin - * Validates Referer against CORS whitelist to prevent token leakage to third parties + * Builds a context-aware redirect URL based on the request's origin Validates Referer against + * CORS whitelist to prevent token leakage to third parties * * @param request The HTTP request * @param contextPath The application context path @@ -226,9 +211,7 @@ public class CustomSaml2AuthenticationSuccessHandler // SECURITY: Only trust Referer if it's in the CORS whitelist // This prevents redirecting with JWT to untrusted domains (e.g., IdP domain) if (isOriginWhitelisted(origin)) { - log.debug( - "Using whitelisted Referer origin for redirect: {}", - origin); + log.debug("Using whitelisted Referer origin for redirect: {}", origin); return origin + "/auth/callback"; } else { log.warn( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index b6c6e2e231..93ad3da400 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -13,7 +13,7 @@ import java.util.Optional; import java.util.function.Function; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -26,7 +26,9 @@ import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.security.SignatureException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; @@ -40,8 +42,14 @@ public class JwtService implements JwtServiceInterface { private static final String ISSUER = "https://stirling.com"; private static final long EXPIRATION = 3600000; + private static final String JWT_COOKIE_NAME = "stirling_jwt"; + private static final String REFRESH_TOKEN_COOKIE_NAME = "stirling_refresh"; + + private final KeyPersistenceServiceInterface keyPersistenceService; + + @Value("${security.jwt.secureCookie:true}") + private boolean secureCookie; - private final @Autowired public JwtService(KeyPersistenceServiceInterface keyPersistenceService) { this.keyPersistenceService = keyPersistenceService; @@ -285,4 +293,97 @@ public class JwtService implements JwtServiceInterface { return null; } } + + /** + * Sets JWT as an HttpOnly cookie for security. Prevents XSS attacks by making token + * inaccessible to JavaScript. + * + * @param response HTTP response to set cookie + * @param jwt JWT token to store + * @param contextPath Application context path for cookie path + */ + public void setJwtCookie(HttpServletResponse response, String jwt, String contextPath) { + Cookie cookie = new Cookie(JWT_COOKIE_NAME, jwt); + + cookie.setHttpOnly(true); + cookie.setSecure(secureCookie); + cookie.setPath(contextPath.isEmpty() ? "/" : contextPath); + cookie.setMaxAge((int) (EXPIRATION / 1000)); + cookie.setAttribute("SameSite", "Lax"); + response.addCookie(cookie); + + log.debug( + "JWT cookie set with secure={}, maxAge={}, path={}", + secureCookie, + (EXPIRATION / 1000), + contextPath.isEmpty() ? "/" : contextPath); + } + + /** + * Sets refresh token as an HttpOnly cookie for security. + * + * @param response HTTP response to set cookie + * @param refreshToken Refresh token to store + * @param contextPath Application context path for cookie path + * @param maxAge Maximum age in seconds + */ + public void setRefreshTokenCookie( + HttpServletResponse response, String refreshToken, String contextPath, int maxAge) { + Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken); + cookie.setHttpOnly(true); // Prevent JavaScript access (XSS protection) + cookie.setSecure(secureCookie); // Only send over HTTPS (configurable for local dev) + cookie.setPath( + contextPath.isEmpty() ? "/" : contextPath); // Cookie available for entire app + cookie.setMaxAge(maxAge); + cookie.setAttribute("SameSite", "Lax"); // CSRF protection + response.addCookie(cookie); + log.debug( + "Refresh token cookie set with secure={}, maxAge={}, path={}", + secureCookie, + maxAge, + contextPath.isEmpty() ? "/" : contextPath); + } + + /** + * Removes JWT cookie from the response (used for logout). + * + * @param response HTTP response to remove cookie + * @param contextPath Application context path for cookie path + */ + public void removeJwtCookie(HttpServletResponse response, String contextPath) { + Cookie cookie = new Cookie(JWT_COOKIE_NAME, ""); + cookie.setHttpOnly(true); + cookie.setSecure(secureCookie); + cookie.setPath(contextPath.isEmpty() ? "/" : contextPath); + cookie.setMaxAge(0); // Expire immediately + cookie.setAttribute("SameSite", "Lax"); + response.addCookie(cookie); + log.debug("JWT cookie removed"); + } + + /** + * Removes refresh token cookie from the response (used for logout). + * + * @param response HTTP response to remove cookie + * @param contextPath Application context path for cookie path + */ + public void removeRefreshTokenCookie(HttpServletResponse response, String contextPath) { + Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, ""); + cookie.setHttpOnly(true); + cookie.setSecure(secureCookie); + cookie.setPath(contextPath.isEmpty() ? "/" : contextPath); + cookie.setMaxAge(0); // Expire immediately + cookie.setAttribute("SameSite", "Lax"); + response.addCookie(cookie); + log.debug("Refresh token cookie removed"); + } + + /** + * Gets the configured secureCookie flag. + * + * @return true if cookies should be set with Secure flag, false otherwise + */ + public boolean isSecureCookie() { + return secureCookie; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java index 2107f2ffd6..2b94c945b8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java @@ -5,6 +5,7 @@ import java.util.Map; import org.springframework.security.core.Authentication; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; public interface JwtServiceInterface { @@ -71,4 +72,48 @@ public interface JwtServiceInterface { * @return true if JWT is enabled, false otherwise */ boolean isJwtEnabled(); + + /** + * Sets JWT as an HttpOnly cookie for security. Prevents XSS attacks by making token + * inaccessible to JavaScript. + * + * @param response HTTP response to set cookie + * @param jwt JWT token to store + * @param contextPath Application context path for cookie path + */ + void setJwtCookie(HttpServletResponse response, String jwt, String contextPath); + + /** + * Sets refresh token as an HttpOnly cookie for security. + * + * @param response HTTP response to set cookie + * @param refreshToken Refresh token to store + * @param contextPath Application context path for cookie path + * @param maxAge Maximum age in seconds + */ + void setRefreshTokenCookie( + HttpServletResponse response, String refreshToken, String contextPath, int maxAge); + + /** + * Removes JWT cookie from the response (used for logout). + * + * @param response HTTP response to remove cookie + * @param contextPath Application context path for cookie path + */ + void removeJwtCookie(HttpServletResponse response, String contextPath); + + /** + * Removes refresh token cookie from the response (used for logout). + * + * @param response HTTP response to remove cookie + * @param contextPath Application context path for cookie path + */ + void removeRefreshTokenCookie(HttpServletResponse response, String contextPath); + + /** + * Gets the configured secureCookie flag. + * + * @return true if cookies should be set with Secure flag, false otherwise + */ + boolean isSecureCookie(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshTokenService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshTokenService.java index 24dcca6142..d4c91fac2c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshTokenService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshTokenService.java @@ -8,6 +8,7 @@ import java.time.LocalDateTime; import java.util.Base64; import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,8 +21,8 @@ import stirling.software.proprietary.security.model.RefreshToken; import stirling.software.proprietary.security.repository.RefreshTokenRepository; /** - * Service for managing refresh tokens - * Implements secure token generation, validation, and revocation + * Service for managing refresh tokens. Implements secure token generation, validation, and + * revocation */ @Slf4j @Service @@ -31,10 +32,9 @@ public class RefreshTokenService { private final RefreshTokenRepository refreshTokenRepository; private final SecureRandom secureRandom = new SecureRandom(); - /** Refresh token validity: 7 days */ - private static final long REFRESH_TOKEN_VALIDITY_DAYS = 7; + @Value("${security.jwt.refreshTokenDays:7}") + private long refreshTokenValidityDays; - /** Refresh token length in bytes (before base64 encoding) */ private static final int TOKEN_LENGTH = 32; /** @@ -46,12 +46,11 @@ public class RefreshTokenService { */ @Transactional public String generateRefreshToken(Long userId, HttpServletRequest request) { - // Generate cryptographically secure random token byte[] tokenBytes = new byte[TOKEN_LENGTH]; secureRandom.nextBytes(tokenBytes); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); - // Hash the token for storage (never store plaintext tokens) + // Hash the token for storage String tokenHash = hashToken(token); // Build refresh token entity @@ -59,7 +58,7 @@ public class RefreshTokenService { RefreshToken.builder() .userId(userId) .tokenHash(tokenHash) - .expiresAt(LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS)) + .expiresAt(LocalDateTime.now().plusDays(refreshTokenValidityDays)) .issuedIp(extractIpAddress(request)) .userAgent(extractUserAgent(request)) .revoked(false) @@ -102,7 +101,9 @@ public class RefreshTokenService { return Optional.empty(); } - log.debug("Refresh token validated successfully for user ID: {}", refreshToken.getUserId()); + log.debug( + "Refresh token validated successfully for user ID: {}", + refreshToken.getUserId()); return Optional.of(refreshToken); } catch (Exception e) { @@ -144,8 +145,8 @@ public class RefreshTokenService { } /** - * Rotates a refresh token (revokes old, generates new) - * Best practice for security: rotate tokens on each refresh + * Rotates a refresh token (revokes old, generates new) Best practice for security: rotate + * tokens on each refresh * * @param oldToken The old refresh token to revoke * @param userId User ID @@ -185,15 +186,14 @@ public class RefreshTokenService { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hash); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("SHA-256 algorithm not available", e); } } - /** - * Converts byte array to hex string - */ + /** Converts byte array to hex string */ private String bytesToHex(byte[] bytes) { StringBuilder result = new StringBuilder(); for (byte b : bytes) { @@ -202,9 +202,7 @@ public class RefreshTokenService { return result.toString(); } - /** - * Extracts IP address from request - */ + /** Extracts IP address from request */ private String extractIpAddress(HttpServletRequest request) { if (request == null) { return null; @@ -232,9 +230,7 @@ public class RefreshTokenService { return ip; } - /** - * Extracts user agent from request - */ + /** Extracts user agent from request */ private String extractUserAgent(HttpServletRequest request) { if (request == null) { return null; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 80e48dbf63..152daa4f9d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -378,6 +378,10 @@ public class UserService implements UserServiceInterface { } } + public Optional findById(Long id) { + return userRepository.findById(id); + } + public Optional findByUsername(String username) { return userRepository.findByUsername(username); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java index e8a6d60453..fe6b94a601 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -64,7 +64,7 @@ class JwtServiceTest { Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey); - jwtService = new JwtService(true, keystoreService); + jwtService = new JwtService(keystoreService); } @Test diff --git a/frontend/src/auth/springAuthClient.ts b/frontend/src/auth/springAuthClient.ts index 03ea08f0da..63fbc98829 100644 --- a/frontend/src/auth/springAuthClient.ts +++ b/frontend/src/auth/springAuthClient.ts @@ -2,9 +2,10 @@ * Spring Auth Client * * This client integrates with the Spring Security + JWT backend. - * - Uses localStorage for JWT storage (sent via Authorization header) + * - Uses HttpOnly cookies for JWT storage (automatic secure storage) * - JWT validation handled server-side * - No email confirmation flow (auto-confirmed on registration) + * - Refresh tokens stored in separate HttpOnly cookie */ // Auth types @@ -44,17 +45,52 @@ export type AuthChangeEvent = type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void; +export interface AuthConfig { + secureCookie: boolean; + jwtEnabled: boolean; +} + class SpringAuthClient { private listeners: AuthChangeCallback[] = []; private sessionCheckInterval: NodeJS.Timeout | null = null; private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry + private authConfig: AuthConfig | null = null; constructor() { + // Load auth config + this.loadAuthConfig(); // Start periodic session validation this.startSessionMonitoring(); } + /** + * Load authentication configuration from backend + */ + private async loadAuthConfig() { + try { + const response = await fetch('/api/v1/auth/config', { + credentials: 'include', + }); + + if (response.ok) { + this.authConfig = await response.json(); + console.debug('[SpringAuth] Auth config loaded:', this.authConfig); + } else { + console.warn('[SpringAuth] Failed to load auth config'); + } + } catch (error) { + console.error('[SpringAuth] Error loading auth config:', error); + } + } + + /** + * Get authentication configuration + */ + getAuthConfig(): AuthConfig | null { + return this.authConfig; + } + /** * Helper to get CSRF token from cookie */ @@ -71,38 +107,26 @@ class SpringAuthClient { /** * Get current session - * JWT is stored in localStorage and sent via Authorization header + * JWT is stored in HttpOnly cookie (automatic with credentials: 'include') */ async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> { try { - // Get JWT from localStorage - const token = localStorage.getItem('stirling_jwt'); - - if (!token) { - console.debug('[SpringAuth] getSession: No JWT in localStorage'); - return { data: { session: null }, error: null }; - } - - // Verify with backend + // Verify with backend (JWT automatically sent via HttpOnly cookie) const response = await fetch('/api/v1/auth/me', { - headers: { - 'Authorization': `Bearer ${token}`, - }, + credentials: 'include', // Include cookies }); if (!response.ok) { - // 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 }; } const data = await response.json(); - // Create session object + // Create session object (no access_token in JS - it's in HttpOnly cookie) const session: Session = { user: data.user, - access_token: token, + access_token: '', // Not accessible to JavaScript (stored in HttpOnly cookie) expires_in: 3600, expires_at: Date.now() + 3600 * 1000, }; @@ -111,8 +135,6 @@ class SpringAuthClient { return { data: { session }, error: null }; } catch (error) { console.error('[SpringAuth] getSession error:', error); - // Clear potentially invalid token - localStorage.removeItem('stirling_jwt'); return { data: { session: null }, error: { message: error instanceof Error ? error.message : 'Unknown error' }, @@ -144,15 +166,13 @@ class SpringAuthClient { } const data = await response.json(); - const token = data.session.access_token; - // Store JWT in localStorage - localStorage.setItem('stirling_jwt', token); - console.log('[SpringAuth] JWT stored in localStorage'); + // JWT is now stored in HttpOnly cookie by backend (no localStorage needed) + console.log('[SpringAuth] JWT stored in HttpOnly cookie by backend'); const session: Session = { user: data.user, - access_token: token, + access_token: '', // Not accessible to JavaScript (stored in HttpOnly cookie) expires_in: data.session.expires_in, expires_at: Date.now() + data.session.expires_in * 1000, }; @@ -233,13 +253,11 @@ class SpringAuthClient { } /** - * Sign out + * Sign out - revokes refresh tokens and clears HttpOnly cookies */ async signOut(): Promise<{ error: AuthError | null }> { try { - // Clear JWT from localStorage immediately - localStorage.removeItem('stirling_jwt'); - console.log('[SpringAuth] JWT removed from localStorage'); + console.log('[SpringAuth] Signing out, clearing HttpOnly cookies'); const csrfToken = this.getCsrfToken(); const headers: HeadersInit = {}; @@ -248,7 +266,7 @@ class SpringAuthClient { headers['X-XSRF-TOKEN'] = csrfToken; } - // Notify backend (optional - mainly for session cleanup) + // Notify backend to revoke refresh tokens and clear cookies await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include', @@ -261,8 +279,6 @@ class SpringAuthClient { return { error: null }; } catch (error) { console.error('[SpringAuth] signOut error:', error); - // Still remove token even if backend call fails - localStorage.removeItem('stirling_jwt'); return { error: { message: error instanceof Error ? error.message : 'Sign out failed' }, }; @@ -270,50 +286,35 @@ class SpringAuthClient { } /** - * Refresh session token + * Refresh session token using refresh token (automatically sent via HttpOnly cookie) */ async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> { try { - const currentToken = localStorage.getItem('stirling_jwt'); - - if (!currentToken) { - return { data: { session: null }, error: { message: 'No token to refresh' } }; - } - + // Refresh token is automatically sent via HttpOnly cookie const response = await fetch('/api/v1/auth/refresh', { method: 'POST', - headers: { - 'Authorization': `Bearer ${currentToken}`, - }, + credentials: 'include', }); if (!response.ok) { - localStorage.removeItem('stirling_jwt'); return { data: { session: null }, error: { message: 'Token refresh failed' } }; } - const refreshData = await response.json(); - const newToken = refreshData.access_token; - - // Store new token - localStorage.setItem('stirling_jwt', newToken); + // Backend sets new access token and refresh token in HttpOnly cookies // Get updated user info const userResponse = await fetch('/api/v1/auth/me', { - headers: { - 'Authorization': `Bearer ${newToken}`, - }, + credentials: 'include', }); if (!userResponse.ok) { - localStorage.removeItem('stirling_jwt'); return { data: { session: null }, error: { message: 'Failed to get user info' } }; } const userData = await userResponse.json(); const session: Session = { user: userData.user, - access_token: newToken, + access_token: '', // Not accessible to JavaScript (stored in HttpOnly cookie) expires_in: 3600, expires_at: Date.now() + 3600 * 1000, }; @@ -324,7 +325,6 @@ class SpringAuthClient { return { data: { session }, error: null }; } catch (error) { console.error('[SpringAuth] refreshSession error:', error); - localStorage.removeItem('stirling_jwt'); return { data: { session: null }, error: { message: error instanceof Error ? error.message : 'Refresh failed' }, diff --git a/frontend/src/routes/AuthCallback.tsx b/frontend/src/routes/AuthCallback.tsx index 285d582512..c7f8331388 100644 --- a/frontend/src/routes/AuthCallback.tsx +++ b/frontend/src/routes/AuthCallback.tsx @@ -6,8 +6,8 @@ import { useAuth } from '../auth/UseSession' * OAuth Callback Handler * * This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back. - * The JWT is passed in the URL fragment (#access_token=...) by the Spring backend. - * We extract it, store in localStorage, and redirect to the home page. + * The JWT is now stored in an HttpOnly cookie by the Spring backend (secure, no localStorage). + * We just need to verify the authentication and redirect to the home page. */ export default function AuthCallback() { const navigate = useNavigate() @@ -18,30 +18,21 @@ export default function AuthCallback() { try { console.log('[AuthCallback] Handling OAuth callback...') - // Extract JWT from URL fragment (#access_token=...) - const hash = window.location.hash.substring(1) // Remove '#' - const params = new URLSearchParams(hash) - const token = params.get('access_token') + // JWT is now stored in HttpOnly cookie by backend - just refresh session to verify + const result = await refreshSession() - if (!token) { - console.error('[AuthCallback] No access_token in URL fragment') + if (result.error || !result.data.session) { + console.error('[AuthCallback] Authentication verification failed:', result.error) navigate('/login', { replace: true, - state: { error: 'OAuth login failed - no token received.' } + state: { error: 'OAuth login failed - authentication could not be verified.' } }) return } - // Store JWT in localStorage - localStorage.setItem('stirling_jwt', token) - console.log('[AuthCallback] JWT stored in localStorage') + console.log('[AuthCallback] Authentication verified, redirecting to home') - // Refresh session to load user info into state - await refreshSession() - - console.log('[AuthCallback] Session refreshed, redirecting to home') - - // Clear the hash from URL and redirect to home page + // Redirect to home page navigate('/', { replace: true }) } catch (error) { console.error('[AuthCallback] Error:', error)