mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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]>
515 lines
15 KiB
TypeScript
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;
|