Login Refresh Fix (#4779)

Main Issues Fixed:

  1. Tools Disabled on Initial Login (Required Page Refresh)

Problem: After successful login, all PDF tools appeared grayed
out/disabled until the user refreshed the page.

Root Cause: Race condition where tools checked endpoint availability
before JWT was stored in localStorage.

  Fix:
- Implemented optimistic defaults in useEndpointConfig - assumes
endpoints are enabled when no JWT exists
- Added JWT availability event system (jwt-available event) to notify
components when authentication is ready
- Tools now remain enabled during auth initialization instead of
defaulting to disabled

  2. Session Lost on Page Refresh (Immediate Logout)

Problem: Users were immediately logged out when refreshing the page,
losing their authenticated session.

  Root Causes:
- Spring Security form login was redirecting API calls to /login with
302 responses instead of returning JSON
  - /api/v1/auth/me endpoint was incorrectly in the permitAll list
- JWT filter wasn't allowing /api/v1/config endpoints without
authentication

  Fixes:
- Backend: Disabled form login in v2/JWT mode by adding && !v2Enabled
condition to form login configuration
- Backend: Removed /api/v1/auth/me from permitAll list - it now requires
authentication
  - Backend: Added /api/v1/config to public endpoints in JWT filter
- Backend: Configured proper exception handling for API endpoints to
return JSON (401) instead of HTML redirects (302)

  3. Multiple Duplicate API Calls

Problem: After login, /app-config was called 5+ times,
/endpoints-enabled and /me called multiple times, causing unnecessary
network traffic.

Root Cause: Multiple React components each had their own instance of
useAppConfig and useEndpointConfig hooks, each fetching data
independently.

  Fix:
- Frontend: Created singleton AppConfigContext provider to ensure only
one global config fetch
- Frontend: Added global caching to useEndpointConfig with module-level
cache variables
- Frontend: Implemented fetch deduplication with fetchCount tracking and
globalFetchedSets
- Result: Reduced API calls from 5+ to 1-2 per endpoint (2 in dev due to
React StrictMode)

  Additional Improvements:

  CORS Configuration

  - Added flexible CORS configuration matching SaaS pattern
- Explicitly allows localhost development ports (3000, 5173, 5174, etc.)
  - No hardcoded URLs in application.properties

  Security Handlers Integration

  - Added IP-based account locking without dependency on form login
  - Preserved audit logging with @Audited annotations

  Key Code Changes:

  Backend Files:
- SecurityConfiguration.java - Disabled form login for v2, added CORS
config
- JwtAuthenticationFilter.java - Added /api/v1/config to public
endpoints
  - JwtAuthenticationEntryPoint.java - Returns JSON for API requests

  Frontend Files:
  - AppConfigContext.tsx - New singleton context for app configuration
  - useEndpointConfig.ts - Added global caching and deduplication
  - UseSession.tsx - Removed redundant config checking
- Various hooks - Updated to use context providers instead of direct
fetching

---------

Signed-off-by: dependabot[bot] <[email protected]>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Ethan <[email protected]>
Co-authored-by: Anthony Stirling <[email protected]>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
Dario Ghunney Ware
2025-11-06 15:42:22 +00:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Ludy EthanHealy01 Ethan Anthony Stirling stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> ConnorYoh Connor Yoh
parent 9440e99227
commit f5c67a3239
18 changed files with 760 additions and 197 deletions
@@ -1,3 +1,5 @@
import { BASE_PATH } from '@app/constants/app';
/**
* Spring Auth Client
*
@@ -7,6 +9,37 @@
* - No email confirmation flow (auto-confirmed on registration)
*/
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`;
function normalizeRedirectPath(target?: string): string {
if (!target || typeof target !== 'string') {
return DEFAULT_REDIRECT_PATH;
}
try {
const parsed = new URL(target, window.location.origin);
const path = parsed.pathname || '/';
const query = parsed.search || '';
return `${path}${query}`;
} catch {
const trimmed = target.trim();
if (!trimmed) {
return DEFAULT_REDIRECT_PATH;
}
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
}
}
function persistRedirectPath(path: string): void {
try {
document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`;
} catch (error) {
console.warn('[SpringAuth] Failed to persist OAuth redirect path', error);
}
}
// Auth types
export interface User {
id: string;
@@ -85,20 +118,44 @@ class SpringAuthClient {
}
// Verify with backend
console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
const response = await fetch('/api/v1/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
console.debug('[SpringAuth] /me response status:', response.status);
const contentType = response.headers.get('content-type');
console.debug('[SpringAuth] /me content-type:', contentType);
if (!response.ok) {
// Log the error response for debugging
const errorBody = await response.text();
console.error('[SpringAuth] getSession: /api/v1/auth/me failed', {
status: response.status,
statusText: response.statusText,
body: errorBody
});
// 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 };
console.warn('[SpringAuth] getSession: Cleared invalid JWT from localStorage');
return { data: { session: null }, error: { message: `Auth failed: ${response.status}` } };
}
// Check if response is JSON before parsing
if (!contentType?.includes('application/json')) {
const text = await response.text();
console.error('[SpringAuth] /me returned non-JSON:', {
contentType,
bodyPreview: text.substring(0, 200)
});
throw new Error(`/api/v1/auth/me returned HTML instead of JSON`);
}
const data = await response.json();
console.debug('[SpringAuth] /me response data:', data);
// Create session object
const session: Session = {
@@ -151,6 +208,9 @@ class SpringAuthClient {
localStorage.setItem('stirling_jwt', token);
console.log('[SpringAuth] JWT stored in localStorage');
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
const session: Session = {
user: data.user,
access_token: token,
@@ -220,6 +280,9 @@ class SpringAuthClient {
options?: { redirectTo?: string; queryParams?: Record<string, any> };
}): Promise<{ error: AuthError | null }> {
try {
const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
persistRedirectPath(redirectPath);
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend)
const redirectUrl = `/oauth2/authorization/${params.provider}`;
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
@@ -299,6 +362,9 @@ class SpringAuthClient {
// Store new token
localStorage.setItem('stirling_jwt', newToken);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
// Get updated user info
const userResponse = await fetch('/api/v1/auth/me', {
headers: {