Fixed oauth logins, moved jwt storage from cookie to auth header
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
-1
@@ -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, "/");
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+5
-5
@@ -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,
|
||||
|
||||
+1
-8
@@ -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);
|
||||
|
||||
|
||||
-1
@@ -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;
|
||||
}
|
||||
|
||||
+15
-3
@@ -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 + "/");
|
||||
|
||||
+29
-8
@@ -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<ClientRegistration> 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<ClientRegistration> 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<ClientRegistration> 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<ClientRegistration> 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();
|
||||
}
|
||||
|
||||
|
||||
+19
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-64
@@ -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;
|
||||
|
||||
-15
@@ -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
|
||||
*
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}` : './',
|
||||
|
||||
Reference in New Issue
Block a user