Fixed oauth logins, moved jwt storage from cookie to auth header

This commit is contained in:
Dario Ghunney Ware
2025-10-21 15:57:44 +01:00
parent 38a97dc3e3
commit 833791b603
18 changed files with 212 additions and 167 deletions
@@ -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
@@ -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, "/");
@@ -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
@@ -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,
@@ -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);
@@ -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;
}
@@ -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 + "/");
@@ -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();
}
@@ -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);
}
}
}
@@ -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;
@@ -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
*
+68 -22
View File
@@ -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' },
+9 -1
View File
@@ -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}`);
+12 -2
View File
@@ -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}`);
+21 -4
View File
@@ -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)
+10 -12
View File
@@ -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
View File
@@ -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}` : './',