Files
Stirling-PDF/frontend/src/proprietary/auth/springAuthClient.ts
T
Dario Ghunney WareGitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>LudyEthanHealy01EthanAnthony Stirlingstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>ConnorYohConnor Yoh
f5c67a3239 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]>
2025-11-06 15:42:22 +00:00

515 lines
15 KiB
TypeScript

import { BASE_PATH } from '@app/constants/app';
/**
* Spring Auth Client
*
* This client integrates with the Spring Security + JWT backend.
* - Uses localStorage for JWT storage (sent via Authorization header)
* - JWT validation handled server-side
* - 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;
email: string;
username: string;
role: string;
enabled?: boolean;
is_anonymous?: boolean;
isFirstLogin?: boolean;
app_metadata?: Record<string, any>;
}
export interface Session {
user: User;
access_token: string;
expires_in: number;
expires_at?: number;
}
export interface AuthError {
message: string;
status?: number;
}
export interface AuthResponse {
user: User | null;
session: Session | null;
error: AuthError | null;
}
export type AuthChangeEvent =
| 'SIGNED_IN'
| 'SIGNED_OUT'
| 'TOKEN_REFRESHED'
| 'USER_UPDATED';
type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void;
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
private sessionCheckInterval: NodeJS.Timeout | null = null;
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
constructor() {
// Start periodic session validation
this.startSessionMonitoring();
}
/**
* Helper to get CSRF token from cookie
*/
private getCsrfToken(): string | null {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return value;
}
}
return null;
}
/**
* Get current session
* JWT is stored in localStorage and sent via Authorization header
*/
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Get JWT from localStorage
const token = localStorage.getItem('stirling_jwt');
if (!token) {
console.debug('[SpringAuth] getSession: No JWT in localStorage');
return { data: { session: null }, error: null };
}
// Verify with backend
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.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 = {
user: data.user,
access_token: token,
expires_in: 3600,
expires_at: Date.now() + 3600 * 1000,
};
console.debug('[SpringAuth] getSession: Session retrieved successfully');
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' },
};
}
}
/**
* Sign in with email and password
*/
async signInWithPassword(credentials: {
email: string;
password: string;
}): Promise<AuthResponse> {
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Include cookies for CSRF
body: JSON.stringify({
username: credentials.email,
password: credentials.password
}),
});
if (!response.ok) {
const error = await response.json();
return { user: null, session: null, error: { message: error.error || 'Login failed' } };
}
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');
// 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,
expires_in: data.session.expires_in,
expires_at: Date.now() + data.session.expires_in * 1000,
};
// Notify listeners
this.notifyListeners('SIGNED_IN', session);
return { user: data.user, session, error: null };
} catch (error) {
console.error('[SpringAuth] signInWithPassword error:', error);
return {
user: null,
session: null,
error: { message: error instanceof Error ? error.message : 'Login failed' },
};
}
}
/**
* Sign up new user
*/
async signUp(credentials: {
email: string;
password: string;
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
}): Promise<AuthResponse> {
try {
const response = await fetch('/api/v1/user/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
username: credentials.email,
password: credentials.password,
}),
});
if (!response.ok) {
const error = await response.json();
return { user: null, session: null, error: { message: error.error || 'Registration failed' } };
}
const data = await response.json();
// Note: Spring backend auto-confirms users (no email verification)
// Return user but no session (user needs to login)
return { user: data.user, session: null, error: null };
} catch (error) {
console.error('[SpringAuth] signUp error:', error);
return {
user: null,
session: null,
error: { message: error instanceof Error ? error.message : 'Registration failed' },
};
}
}
/**
* Sign in with OAuth provider (GitHub, Google, etc.)
* This redirects to the Spring OAuth2 authorization endpoint
*/
async signInWithOAuth(params: {
provider: 'github' | 'google' | 'apple' | 'azure';
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);
// Use window.location.assign for full page navigation
window.location.assign(redirectUrl);
return { error: null };
} catch (error) {
return {
error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' },
};
}
}
/**
* Sign out
*/
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
this.notifyListeners('SIGNED_OUT', null);
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' },
};
}
}
/**
* Refresh session token
*/
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',
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);
// 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: {
'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: newToken,
expires_in: 3600,
expires_at: Date.now() + 3600 * 1000,
};
// Notify listeners
this.notifyListeners('TOKEN_REFRESHED', session);
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' },
};
}
}
/**
* Listen to auth state changes
*/
onAuthStateChange(callback: AuthChangeCallback): { data: { subscription: { unsubscribe: () => void } } } {
this.listeners.push(callback);
return {
data: {
subscription: {
unsubscribe: () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
},
},
},
};
}
// Private helper methods
private notifyListeners(event: AuthChangeEvent, session: Session | null) {
// Use setTimeout to avoid calling callbacks synchronously
setTimeout(() => {
this.listeners.forEach((callback) => {
try {
callback(event, session);
} catch (error) {
console.error('[SpringAuth] Error in auth state change listener:', error);
}
});
}, 0);
}
private startSessionMonitoring() {
// Periodically check session validity
// Since we use HttpOnly cookies, we just need to check with the server
this.sessionCheckInterval = setInterval(async () => {
try {
// Try to get current session
const { data } = await this.getSession();
// If we have a session, proactively refresh if needed
// (The server will handle token expiry, but we can be proactive)
if (data.session) {
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
// Refresh if token expires soon
if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) {
console.log('[SpringAuth] Proactively refreshing token');
await this.refreshSession();
}
}
} catch (error) {
console.error('[SpringAuth] Session monitoring error:', error);
}
}, this.SESSION_CHECK_INTERVAL);
}
public destroy() {
if (this.sessionCheckInterval) {
clearInterval(this.sessionCheckInterval);
}
}
}
export const springAuth = new SpringAuthClient();
/**
* Get current user
*/
export const getCurrentUser = async () => {
const { data } = await springAuth.getSession();
return data.session?.user || null;
};
/**
* Check if user is anonymous
*/
export const isUserAnonymous = (user: User | null) => {
return user?.is_anonymous === true;
};
/**
* Create an anonymous user object for use when login is disabled
* This provides a consistent User interface throughout the app
*/
export const createAnonymousUser = (): User => {
return {
id: 'anonymous',
email: 'anonymous@local',
username: 'Anonymous User',
role: 'USER',
enabled: true,
is_anonymous: true,
app_metadata: {
provider: 'anonymous',
},
};
};
/**
* Create an anonymous session for use when login is disabled
*/
export const createAnonymousSession = (): Session => {
return {
user: createAnonymousUser(),
access_token: '',
expires_in: Number.MAX_SAFE_INTEGER,
expires_at: Number.MAX_SAFE_INTEGER,
};
};
// Export auth client as default for convenience
export default springAuth;