diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index 0a215ef234..d8b02b38fc 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -2,10 +2,10 @@ multipart.enabled=true logging.level.org.springframework=WARN logging.level.org.hibernate=WARN logging.level.org.eclipse.jetty=WARN -#logging.level.org.springframework.security.saml2=TRACE +logging.level.org.springframework.security.oauth2=DEBUG #logging.level.org.springframework.security=DEBUG #logging.level.org.opensaml=DEBUG -#logging.level.stirling.software.proprietary.security=DEBUG +logging.level.stirling.software.proprietary.security=DEBUG logging.level.com.zaxxer.hikari=WARN spring.jpa.open-in-view=false server.forward-headers-strategy=NATIVE @@ -16,7 +16,7 @@ server.error.include-exception=true server.error.include-message=always #logging.level.org.springframework.web=DEBUG #logging.level.org.springframework=DEBUG -#logging.level.org.springframework.security=DEBUG +logging.level.org.springframework.security=DEBUG spring.servlet.multipart.max-file-size=2000MB spring.servlet.multipart.max-request-size=2000MB @@ -35,12 +35,12 @@ spring.datasource.username=sa spring.datasource.password= spring.h2.console.enabled=false spring.jpa.hibernate.ddl-auto=update -# Defer datasource initialization to ensure that the database is fully set up -# before Hibernate attempts to access it. This is particularly useful when +# Defer datasource initialization to ensure that the database is fully set up +# before Hibernate attempts to access it. This is particularly useful when # using database initialization scripts or tools. spring.jpa.defer-datasource-initialization=true -# Disable SQL logging to avoid cluttering the logs in production. Enable this +# Disable SQL logging to avoid cluttering the logs in production. Enable this # property during development if you need to debug SQL queries. spring.jpa.show-sql=false server.servlet.session.timeout:30m @@ -61,3 +61,6 @@ java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} # V2 features v2=true + +# OAuth2 configuration +security.oauth2.enabled=true diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 6bf882685b..28e07c1775 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -20,7 +20,7 @@ security: username: '' # initial username for the first login password: '' # initial password for the first login oauth2: - enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work) + enabled: true # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work) client: keycloak: issuer: '' # URL of the Keycloak realm's OpenID Connect Discovery endpoint 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 51908ef03c..028cee6850 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 @@ -57,7 +57,6 @@ public class CustomAuthenticationSuccessHandler String jwt = jwtService.generateToken( authentication, Map.of("authType", AuthenticationType.WEB)); - jwtService.addToken(response, jwt); log.debug("JWT generated for user: {}", userName); getRedirectStrategy().sendRedirect(request, response, "/"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 1361205289..272b7e407f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -72,7 +72,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } } else if (!jwtService.extractToken(request).isBlank()) { - jwtService.clearToken(response); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } else { // Redirect to login page after logout 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 889391e581..33ad3cf671 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 @@ -299,18 +299,18 @@ public class SecurityConfiguration { if (securityProperties.isOauth2Active()) { http.oauth2Login( oauth2 -> { - // v2: Don't set loginPage, let default OAuth2 flow handle it // 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. - */ + 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, 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 ee8dea7a24..654f9b5d46 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 @@ -81,12 +81,9 @@ public class AuthController { String token = jwtService.generateToken(user.getUsername(), claims); - // Set JWT cookie (HttpOnly for security) - jwtService.addToken(response, token); - log.info("Login successful for user: {}", request.email()); - // Return user info (matches Supabase response structure) + // Return user info return ResponseEntity.ok( Map.of( "user", buildUserResponse(user), @@ -204,9 +201,6 @@ public class AuthController { @PostMapping("/logout") public ResponseEntity logout(HttpServletResponse response) { try { - // Clear JWT cookie - jwtService.clearToken(response); - // Clear security context SecurityContextHolder.clearContext(); @@ -251,7 +245,6 @@ public class AuthController { claims.put("role", user.getRolesAsString()); String newToken = jwtService.generateToken(username, claims); - jwtService.addToken(response, newToken); log.debug("Token refreshed for user: {}", username); 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 69d37ba281..5506b7e07c 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 @@ -118,7 +118,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { log.debug("JWT token validated successfully"); } catch (AuthenticationFailureException e) { log.warn("JWT validation failed: {}", e.getMessage()); - jwtService.clearToken(response); handleAuthenticationFailure(request, response, e); return; } 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 99daa6c446..32ddf1e9d4 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 @@ -102,9 +102,21 @@ public class CustomOAuth2AuthenticationSuccessHandler String jwt = jwtService.generateToken( authentication, Map.of("authType", AuthenticationType.OAUTH2)); - jwtService.addToken(response, jwt); - // Redirect to auth callback for v2 (React will handle final routing) - response.sendRedirect(contextPath + "/auth/callback"); + + // Check if backend is running on port 8080 (dev mode indicator) + String serverPort = String.valueOf(request.getServerPort()); + String redirectUrl; + + if ("8080".equals(serverPort)) { + // Dev mode: assume frontend is on 5173 or 5174 + // Try 5173 first (default Vite port) + redirectUrl = "http://localhost:5173/auth/callback#access_token=" + jwt; + } else { + // Prod mode: same origin + redirectUrl = contextPath + "/auth/callback#access_token=" + jwt; + } + + response.sendRedirect(redirectUrl); } else { // v1: redirect directly to home response.sendRedirect(contextPath + "/"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java index 913dc458ac..cd04d6da06 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java @@ -10,7 +10,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -41,7 +40,7 @@ import stirling.software.proprietary.security.service.UserService; @Slf4j @Configuration -@ConditionalOnBooleanProperty("security.oauth2.enabled") +@ConditionalOnProperty(prefix = "security", name = "oauth2.enabled", havingValue = "true") public class OAuth2Configuration { public static final String REDIRECT_URI_PATH = "{baseUrl}/login/oauth2/code/"; @@ -53,6 +52,9 @@ public class OAuth2Configuration { ApplicationProperties applicationProperties, @Lazy UserService userService) { this.userService = userService; this.applicationProperties = applicationProperties; + log.info( + "OAuth2Configuration initialized - OAuth2 enabled: {}", + applicationProperties.getSecurity().getOauth2().getEnabled()); } @Bean @@ -75,7 +77,7 @@ public class OAuth2Configuration { private Optional keycloakClientRegistration() { OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oauth2) || isClientInitialised(oauth2)) { + if (isOAuth2Disabled(oauth2) || isClientInitialised(oauth2)) { return Optional.empty(); } @@ -105,7 +107,7 @@ public class OAuth2Configuration { private Optional googleClientRegistration() { OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oAuth2) || isClientInitialised(oAuth2)) { + if (isOAuth2Disabled(oAuth2) || isClientInitialised(oAuth2)) { return Optional.empty(); } @@ -138,12 +140,23 @@ public class OAuth2Configuration { private Optional githubClientRegistration() { OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oAuth2)) { + if (isOAuth2Disabled(oAuth2)) { + log.debug("OAuth2 is disabled, skipping GitHub client registration"); return Optional.empty(); } Client client = oAuth2.getClient(); + if (client == null) { + log.debug("OAuth2 client configuration is null, skipping GitHub"); + return Optional.empty(); + } + GitHubProvider githubClient = client.getGithub(); + if (githubClient == null) { + log.debug("GitHub client configuration is null"); + return Optional.empty(); + } + Provider github = new GitHubProvider( githubClient.getClientId(), @@ -151,7 +164,15 @@ public class OAuth2Configuration { githubClient.getScopes(), githubClient.getUseAsUsername()); - return validateProvider(github) + boolean isValid = validateProvider(github); + log.info( + "GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})", + isValid, + githubClient.getClientId(), + githubClient.getClientSecret() != null ? "***" : "null", + githubClient.getScopes()); + + return isValid ? Optional.of( ClientRegistration.withRegistrationId(github.getName()) .clientId(github.getClientId()) @@ -171,7 +192,7 @@ public class OAuth2Configuration { private Optional oidcClientRegistration() { OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oauth) || isClientInitialised(oauth)) { + if (isOAuth2Disabled(oauth) || isClientInitialised(oauth)) { return Optional.empty(); } @@ -207,7 +228,7 @@ public class OAuth2Configuration { : Optional.empty(); } - private boolean isOAuth2Enabled(OAUTH2 oAuth2) { + private boolean isOAuth2Disabled(OAUTH2 oAuth2) { return oAuth2 == null || !oAuth2.getEnabled(); } 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 1575f86431..29feb3fe94 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 @@ -121,11 +121,26 @@ public class CustomSaml2AuthenticationSuccessHandler username, saml2Properties.getAutoCreateUser(), SAML2); log.debug("Successfully processed authentication for user: {}", username); - generateJwt(response, authentication); - - // Redirect to auth callback for v2 (React will handle final routing) + // Generate JWT if v2 is enabled if (jwtService.isJwtEnabled()) { - response.sendRedirect(contextPath + "/auth/callback"); + String jwt = + jwtService.generateToken( + authentication, + Map.of("authType", AuthenticationType.SAML2)); + + // Check if backend is running on port 8080 (dev mode indicator) + String serverPort = String.valueOf(request.getServerPort()); + String redirectUrl; + + if ("8080".equals(serverPort)) { + // Dev mode: assume frontend is on 5173 + redirectUrl = "http://localhost:5173/auth/callback#access_token=" + jwt; + } else { + // Prod mode: same origin + redirectUrl = contextPath + "/auth/callback#access_token=" + jwt; + } + + response.sendRedirect(redirectUrl); } else { // v1: redirect directly to home response.sendRedirect(contextPath + "/"); @@ -142,13 +157,4 @@ public class CustomSaml2AuthenticationSuccessHandler super.onAuthenticationSuccess(request, response, authentication); } } - - private void generateJwt(HttpServletResponse response, Authentication authentication) { - if (jwtService.isJwtEnabled()) { - String jwt = - jwtService.generateToken( - authentication, Map.of("authType", AuthenticationType.SAML2)); - jwtService.addToken(response, jwt); - } - } } 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 51e9e75ad0..a65c79665a 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 @@ -14,14 +14,11 @@ 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.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; -import io.github.pixee.security.Newlines; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -29,9 +26,7 @@ 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; @@ -43,13 +38,9 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin @Service public class JwtService implements JwtServiceInterface { - private static final String JWT_COOKIE_NAME = "stirling_jwt"; private static final String ISSUER = "https://stirling.com"; private static final long EXPIRATION = 3600000; - @Value("${stirling.security.jwt.secureCookie:false}") - private boolean secureCookie = false; // Hardcoded to false for HTTP development - private final KeyPersistenceServiceInterface keyPersistenceService; private final boolean v2Enabled; @@ -59,7 +50,7 @@ public class JwtService implements JwtServiceInterface { KeyPersistenceServiceInterface keyPersistenceService) { this.v2Enabled = v2Enabled; this.keyPersistenceService = keyPersistenceService; - log.info("JwtService initialized with secureCookie={}", secureCookie); + log.info("JwtService initialized"); } @Override @@ -261,7 +252,7 @@ public class JwtService implements JwtServiceInterface { @Override public String extractToken(HttpServletRequest request) { - // First, try to extract from Authorization header (Bearer token) + // Extract from Authorization header Bearer token String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); // Remove "Bearer " prefix @@ -269,62 +260,10 @@ public class JwtService implements JwtServiceInterface { return token; } - // Fall back to cookie-based authentication - Cookie[] cookies = request.getCookies(); - if (cookies != null) { - for (Cookie cookie : cookies) { - if (JWT_COOKIE_NAME.equals(cookie.getName())) { - log.debug("JWT token extracted from cookie"); - return cookie.getValue(); - } - } - } - - log.debug("No JWT token found in Authorization header or cookies"); + log.debug("No JWT token found in Authorization header"); return null; } - @Override - public void addToken(HttpServletResponse response, String token) { - log.info("Setting JWT cookie with secureCookie={}", secureCookie); - ResponseCookie cookie = - ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token)) - .httpOnly( - false) // Set to false for V2 to allow JavaScript to read token for - // Authorization header - .secure(secureCookie) - .sameSite("Lax") // Changed from Strict to Lax for cross-port dev - // compatibility - .maxAge(EXPIRATION / 1000) - .path("/") - .domain("localhost") // Set domain to localhost for cross-port dev - // compatibility - .build(); - - String cookieString = cookie.toString(); - log.info("Set-Cookie header: {}", cookieString); - response.addHeader("Set-Cookie", cookieString); - } - - @Override - public void clearToken(HttpServletResponse response) { - log.info("Clearing JWT cookie"); - ResponseCookie cookie = - ResponseCookie.from(JWT_COOKIE_NAME, "") - .httpOnly(false) // Must match addToken settings - .secure(secureCookie) - .sameSite("Lax") // Must match addToken settings - .maxAge(0) - .path("/") - .domain("localhost") // Set domain to localhost for cross-port dev - // compatibility - .build(); - - String cookieString = cookie.toString(); - log.info("Clear-Cookie header: {}", cookieString); - response.addHeader("Set-Cookie", cookieString); - } - @Override public boolean isJwtEnabled() { return v2Enabled; 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 7cdca82098..9e0855fc4d 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 @@ -66,21 +66,6 @@ public interface JwtServiceInterface { */ String extractToken(HttpServletRequest request); - /** - * Add JWT token to HTTP response (header and cookie) - * - * @param response HTTP servlet response - * @param token JWT token to add - */ - void addToken(HttpServletResponse response, String token); - - /** - * Clear JWT token from HTTP response (remove cookie) - * - * @param response HTTP servlet response - */ - void clearToken(HttpServletResponse response); - /** * Check if JWT authentication is enabled * diff --git a/frontend/src/auth/springAuthClient.ts b/frontend/src/auth/springAuthClient.ts index 2c55a0c71f..697fe31843 100644 --- a/frontend/src/auth/springAuthClient.ts +++ b/frontend/src/auth/springAuthClient.ts @@ -5,7 +5,7 @@ * but integrates with the Spring Security + JWT backend instead. * * Main differences from Supabase: - * - Uses HttpOnly cookies for JWT storage (more secure than localStorage) + * - Uses localStorage for JWT storage (sent via Authorization header) * - JWT validation handled server-side * - No email confirmation flow (auto-confirmed on registration) */ @@ -63,49 +63,53 @@ class SpringAuthClient { } /** - * Helper to get JWT token from cookie and add to fetch headers + * Helper to get CSRF token from cookie */ - private getAuthHeaders(): HeadersInit { + private getCsrfToken(): string | null { const cookies = document.cookie.split(';'); for (const cookie of cookies) { const [name, value] = cookie.trim().split('='); - if (name === 'stirling_jwt') { - return { - 'Authorization': `Bearer ${value}`, - }; + if (name === 'XSRF-TOKEN') { + return value; } } - return {}; + return null; } /** * Get current session - * Note: JWT is stored in HttpOnly cookie, so we can't read it directly - * We check auth status by calling the /me endpoint + * JWT is stored in localStorage and sent via Authorization header */ async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> { try { - // Verify with backend - add Authorization header from cookie - const authHeaders = this.getAuthHeaders(); + // 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 const response = await fetch('/api/v1/auth/me', { - credentials: 'include', // Include cookies headers: { - ...authHeaders, + 'Authorization': `Bearer ${token}`, }, }); if (!response.ok) { - // Not authenticated + // 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 (we don't have access to the actual token due to HttpOnly) + // Create session object const session: Session = { user: data.user, - access_token: '', // HttpOnly cookie, not accessible + access_token: token, expires_in: 3600, expires_at: Date.now() + 3600 * 1000, }; @@ -114,6 +118,8 @@ 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' }, @@ -132,7 +138,7 @@ class SpringAuthClient { const response = await fetch('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'include', // Important: include cookies + credentials: 'include', // Include cookies for CSRF body: JSON.stringify(credentials), }); @@ -142,9 +148,15 @@ 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'); + const session: Session = { user: data.user, - access_token: data.session.access_token, + access_token: token, expires_in: data.session.expires_in, expires_at: Date.now() + data.session.expires_in * 1000, }; @@ -230,9 +242,22 @@ class SpringAuthClient { */ async signOut(): Promise<{ error: AuthError | null }> { try { + // Clear JWT from localStorage immediately + localStorage.removeItem('stirling_jwt'); + console.log('[SpringAuth] JWT removed from localStorage'); + + const csrfToken = this.getCsrfToken(); + const headers: HeadersInit = {}; + + if (csrfToken) { + headers['X-XSRF-TOKEN'] = csrfToken; + } + + // Notify backend (optional - mainly for session cleanup) await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include', + headers, }); // Notify listeners @@ -241,6 +266,8 @@ 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' }, }; @@ -252,28 +279,46 @@ class SpringAuthClient { */ 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' } }; + } + const response = await fetch('/api/v1/auth/refresh', { method: 'POST', - credentials: 'include', + headers: { + 'Authorization': `Bearer ${currentToken}`, + }, }); 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); + // Get updated user info const userResponse = await fetch('/api/v1/auth/me', { - credentials: 'include', + headers: { + 'Authorization': `Bearer ${newToken}`, + }, }); 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: '', // HttpOnly cookie + access_token: newToken, expires_in: 3600, expires_at: Date.now() + 3600 * 1000, }; @@ -284,6 +329,7 @@ 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/hooks/useAppConfig.ts b/frontend/src/hooks/useAppConfig.ts index cfca99b574..d8879ec9d6 100644 --- a/frontend/src/hooks/useAppConfig.ts +++ b/frontend/src/hooks/useAppConfig.ts @@ -1,5 +1,11 @@ import { useState, useEffect } from 'react'; +// Helper to get JWT from localStorage for Authorization header +function getAuthHeaders(): HeadersInit { + const token = localStorage.getItem('stirling_jwt'); + return token ? { 'Authorization': `Bearer ${token}` } : {}; +} + export interface AppConfig { baseUrl?: string; contextPath?: string; @@ -46,7 +52,9 @@ export function useAppConfig(): UseAppConfigReturn { setLoading(true); setError(null); - const response = await fetch('/api/v1/config/app-config'); + const response = await fetch('/api/v1/config/app-config', { + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`); diff --git a/frontend/src/hooks/useEndpointConfig.ts b/frontend/src/hooks/useEndpointConfig.ts index 7516826ede..f87c0187aa 100644 --- a/frontend/src/hooks/useEndpointConfig.ts +++ b/frontend/src/hooks/useEndpointConfig.ts @@ -1,5 +1,11 @@ import { useState, useEffect } from 'react'; +// Helper to get JWT from localStorage for Authorization header +function getAuthHeaders(): HeadersInit { + const token = localStorage.getItem('stirling_jwt'); + return token ? { 'Authorization': `Bearer ${token}` } : {}; +} + /** * Hook to check if a specific endpoint is enabled */ @@ -24,7 +30,9 @@ export function useEndpointEnabled(endpoint: string): { setLoading(true); setError(null); - const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`); + const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, { + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`); @@ -80,7 +88,9 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { // Use batch API for efficiency const endpointsParam = endpoints.join(','); - const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`); + const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`, { + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`); diff --git a/frontend/src/routes/AuthCallback.tsx b/frontend/src/routes/AuthCallback.tsx index c99c70af50..285d582512 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 has already been set as an HttpOnly cookie by the Spring backend. - * We just need to refresh the session state and redirect to the home page. + * 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. */ export default function AuthCallback() { const navigate = useNavigate() @@ -18,13 +18,30 @@ export default function AuthCallback() { try { console.log('[AuthCallback] Handling OAuth callback...') - // JWT already set as cookie by backend + // 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') + + if (!token) { + console.error('[AuthCallback] No access_token in URL fragment') + navigate('/login', { + replace: true, + state: { error: 'OAuth login failed - no token received.' } + }) + return + } + + // Store JWT in localStorage + localStorage.setItem('stirling_jwt', token) + console.log('[AuthCallback] JWT stored in localStorage') + // Refresh session to load user info into state await refreshSession() console.log('[AuthCallback] Session refreshed, redirecting to home') - // Redirect to home page + // Clear the hash from URL and redirect to home page navigate('/', { replace: true }) } catch (error) { console.error('[AuthCallback] Error:', error) diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index a194a64c52..906c3cefb8 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -8,28 +8,26 @@ const apiClient = axios.create({ responseType: 'json', }); -// Helper function to get JWT token from cookies -function getJwtTokenFromCookie(): string | null { - const cookies = document.cookie.split(';'); - for (const cookie of cookies) { - const [name, value] = cookie.trim().split('='); - if (name === 'stirling_jwt') { - return value; - } +// Helper function to get JWT token from localStorage +function getJwtTokenFromStorage(): string | null { + try { + return localStorage.getItem('stirling_jwt'); + } catch (error) { + console.error('[API Client] Failed to read JWT from localStorage:', error); + return null; } - return null; } // ---------- Install request interceptor to add JWT token ---------- apiClient.interceptors.request.use( (config) => { - // Get JWT token from cookie - const jwtToken = getJwtTokenFromCookie(); + // Get JWT token from localStorage + const jwtToken = getJwtTokenFromStorage(); // If token exists and Authorization header is not already set, add it if (jwtToken && !config.headers.Authorization) { config.headers.Authorization = `Bearer ${jwtToken}`; - console.debug('[API Client] Added JWT token from cookie to Authorization header'); + console.debug('[API Client] Added JWT token from localStorage to Authorization header'); } return config; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 59ebfd6634..05d5da57b6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,6 +10,16 @@ export default defineConfig({ changeOrigin: true, secure: false, }, + '/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + }, + '/login/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + }, }, }, base: process.env.RUN_SUBPATH ? `/${process.env.RUN_SUBPATH}` : './',