/** * 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) */ import apiClient from '@app/services/apiClient'; import { AxiosError } from 'axios'; import { BASE_PATH } from '@app/constants/app'; import { type OAuthProvider } from '@app/auth/oauthTypes'; import { resetOAuthState } from '@app/auth/oauthStorage'; import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup'; import { getPlatformSessionUser, isDesktopSaaSAuthMode, refreshPlatformSession, savePlatformToken, } from '@app/extensions/platformSessionBridge'; import { startOAuthNavigation } from '@app/extensions/oauthNavigation'; function getHttpStatus(error: unknown): number | undefined { if (error instanceof AxiosError) { return error.response?.status; } if (error && typeof error === 'object' && 'response' in error) { const response = (error as { response?: { status?: unknown } }).response; if (response && typeof response.status === 'number') { return response.status; } } return undefined; } // Helper to extract error message from axios error function getErrorMessage(error: unknown, fallback: string): string { if (error instanceof AxiosError) { return error.response?.data?.error || error.response?.data?.message || error.message || fallback; } return error instanceof Error ? error.message : fallback; } 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; authenticationType?: string; app_metadata?: Record; } export interface Session { user: User; access_token: string; expires_in: number; expires_at?: number; } export interface AuthError { message: string; status?: number; code?: string; mfaRequired?: boolean; } 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; // Adaptive intervals - calculated based on actual JWT token lifetime // Defaults for initial startup (will be recalculated on first token) private sessionCheckIntervalMs = 10000; // 10 seconds default private tokenRefreshThresholdMs = 30000; // 30 seconds default private readonly DESKTOP_SAAS_REFRESH_EARLY_SECONDS = 60; constructor() { // Start periodic session validation this.startSessionMonitoring(); } /** * Calculate optimal check interval and refresh threshold based on token lifetime. * - Check interval: token lifetime / 6 (check 6 times during token life) * - Refresh threshold: token lifetime / 4 (refresh when 25% remaining) * - Applies min/max bounds for sanity */ private calculateAdaptiveIntervals(token: string): void { try { const payload = this.decodeJwtPayload(token); if (!payload) { console.warn('[SpringAuth] Cannot decode token for adaptive intervals, using defaults'); return; } const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0; const iatSeconds = typeof payload?.iat === 'number' ? payload.iat : 0; if (expSeconds <= 0 || iatSeconds <= 0) { console.warn('[SpringAuth] Token missing exp/iat claims, using default intervals'); return; } const tokenLifetimeMs = (expSeconds - iatSeconds) * 1000; // Check interval: check 6 times during token lifetime // Min: 5 seconds (for very short tokens) // Max: 60 seconds (don't check too infrequently) this.sessionCheckIntervalMs = Math.max(5000, Math.min(60000, tokenLifetimeMs / 6)); // Refresh threshold: refresh when 25% of lifetime remaining // Min: 30 seconds (give buffer for refresh to complete) // Max: 5 minutes (don't wait too long for long-lived tokens) this.tokenRefreshThresholdMs = Math.max(30000, Math.min(300000, tokenLifetimeMs / 4)); console.log('[SpringAuth] 📊 Adaptive intervals calculated:', { tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + 's', checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + 's', refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + 's', }); // Restart monitoring with new interval this.restartSessionMonitoring(); } catch (error) { console.warn('[SpringAuth] Failed to calculate adaptive intervals:', error); } } private decodeJwtPayload(token: string): Record | null { const parts = token.split('.'); if (parts.length < 2) { return null; } const base64Url = parts[1]; const base64 = base64Url .replace(/-/g, '+') .replace(/_/g, '/') .padEnd(Math.ceil(base64Url.length / 4) * 4, '='); return JSON.parse(atob(base64)); } private getTokenExpiry(token: string): { expiresIn: number; expiresAt: number } { try { const payload = this.decodeJwtPayload(token); if (!payload) { throw new Error('Token payload missing'); } const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0; const expiresAt = expSeconds > 0 ? expSeconds * 1000 : Date.now() + 3600 * 1000; const expiresIn = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); return { expiresIn, expiresAt }; } catch { // Fallback for non-JWT or malformed tokens. const expiresAt = Date.now() + 3600 * 1000; return { expiresIn: 3600, expiresAt }; } } /** * 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 decodeURIComponent(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 let token = localStorage.getItem('stirling_jwt'); if (!token) { // console.debug('[SpringAuth] getSession: No JWT in localStorage'); return { data: { session: null }, error: null }; } if (await isDesktopSaaSAuthMode()) { let tokenExpiry = this.getTokenExpiry(token); if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) { const refreshed = await refreshPlatformSession(); if (!refreshed) { localStorage.removeItem('stirling_jwt'); return { data: { session: null }, error: null }; } const refreshedToken = localStorage.getItem('stirling_jwt'); if (!refreshedToken) { localStorage.removeItem('stirling_jwt'); return { data: { session: null }, error: null }; } token = refreshedToken; tokenExpiry = this.getTokenExpiry(token); } if (tokenExpiry.expiresIn <= 0) { localStorage.removeItem('stirling_jwt'); return { data: { session: null }, error: null }; } const platformUser = await getPlatformSessionUser(); const session: Session = { user: { id: platformUser?.email || platformUser?.username || 'desktop-saas-user', email: platformUser?.email || '', username: platformUser?.username || platformUser?.email || 'User', role: 'USER', }, access_token: token, expires_in: tokenExpiry.expiresIn, expires_at: tokenExpiry.expiresAt, }; return { data: { session }, error: null }; } // Verify with backend // Note: We pass the token explicitly here, overriding the interceptor's default // console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me'); const response = await apiClient.get('/api/v1/auth/me', { headers: { 'Authorization': `Bearer ${token}`, }, suppressErrorToast: true, // Suppress global error handler (we handle errors locally) // Session bootstrap should not trigger global 401 refresh/redirect loops. skipAuthRedirect: true, }); // console.debug('[SpringAuth] /me response status:', response.status); const data = response.data; // console.debug('[SpringAuth] /me response data:', data); // Create session object const tokenExpiry = this.getTokenExpiry(token); const session: Session = { user: data.user, access_token: token, expires_in: tokenExpiry.expiresIn, expires_at: tokenExpiry.expiresAt, }; // console.debug('[SpringAuth] getSession: Session retrieved successfully'); return { data: { session }, error: null }; } catch (error: unknown) { console.error('[SpringAuth] getSession error:', error); // If 401/403, token is invalid - try explicit refresh const status = getHttpStatus(error); if (status === 401 || status === 403) { // A 401 during startup can be a race with a concurrent refresh. Try one // explicit refresh before treating the session as invalid. const refreshResult = await this.refreshSession(); if (!refreshResult.error && refreshResult.data.session) { return refreshResult; } localStorage.removeItem('stirling_jwt'); console.debug('[SpringAuth] getSession: Not authenticated'); return { data: { session: null }, error: null }; } // Don't clear token for other errors (e.g., backend not ready, network issues) // The token is still valid, just can't verify it right now return { data: { session: null }, error: { message: getErrorMessage(error, 'Unknown error') }, }; } } /** * Sign in with email and password */ async signInWithPassword(credentials: { email: string; password: string; mfaCode?: string; }): Promise { try { const response = await apiClient.post('/api/v1/auth/login', { username: credentials.email, password: credentials.password, mfaCode: credentials.mfaCode, }, { withCredentials: true, // Include cookies for CSRF }); const data = response.data; const token = data.session.access_token; // Store JWT in localStorage localStorage.setItem('stirling_jwt', token); // console.log('[SpringAuth] JWT stored in localStorage'); // Sync token to platform-specific storage (Tauri store for desktop) await savePlatformToken(token); // Calculate adaptive monitoring intervals based on token lifetime this.calculateAdaptiveIntervals(token); // 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: unknown) { console.error('[SpringAuth] signInWithPassword error:', error); if (error instanceof AxiosError) { const errorCode = error.response?.data?.error as string | undefined; const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || 'Login failed'; return { user: null, session: null, error: { message: errorMessage, status: error.response?.status, code: errorCode, mfaRequired: errorCode === 'mfa_required', }, }; } return { user: null, session: null, error: { message: getErrorMessage(error, 'Login failed') }, }; } } /** * Sign up new user */ async signUp(credentials: { email: string; password: string; options?: { data?: { full_name?: string }; emailRedirectTo?: string }; }): Promise { try { const response = await apiClient.post('/api/v1/user/register', { username: credentials.email, password: credentials.password, }, { withCredentials: true, }); const data = response.data; // 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: unknown) { console.error('[SpringAuth] signUp error:', error); return { user: null, session: null, error: { message: getErrorMessage(error, 'Registration failed') }, }; } } /** * Sign in with OAuth/SAML provider (GitHub, Google, Authentik, etc.) * This redirects to the Spring OAuth2/SAML2 authorization endpoint * * @param params.provider - Full auth path from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling') * The backend provides the complete path including the auth type and provider ID */ async signInWithOAuth(params: { provider: OAuthProvider; options?: { redirectTo?: string; queryParams?: Record }; }): Promise<{ error: AuthError | null }> { try { const redirectPath = normalizeRedirectPath(params.options?.redirectTo); persistRedirectPath(redirectPath); // Use the full path provided by the backend // This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...) const redirectUrl = params.provider; const handled = await startOAuthNavigation(redirectUrl); if (handled) { return { error: null }; } // console.log('[SpringAuth] Redirecting to SSO:', 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 : 'SSO redirect failed' }, }; } } /** * Sign out user (invalidate session) */ async signOut(): Promise<{ error: AuthError | null }> { try { if (typeof window !== 'undefined') { window.sessionStorage.setItem('stirling_sso_auto_login_logged_out', '1'); } const response = await apiClient.post('/api/v1/auth/logout', null, { headers: { 'X-XSRF-TOKEN': this.getCsrfToken() || '', }, withCredentials: true, }); if (response.status === 200) { // console.debug('[SpringAuth] signOut: Success'); } // Clean up local storage localStorage.removeItem('stirling_jwt'); try { Object.keys(localStorage) .filter((key) => key.startsWith('sb-') || key.includes('supabase')) .forEach((key) => localStorage.removeItem(key)); // Clear any cached OAuth redirect/session state resetOAuthState(); } catch (err) { console.warn('[SpringAuth] Failed to clear Supabase/local auth tokens', err); } // Clear cookies that might hold refresh/session tokens try { document.cookie.split(';').forEach(cookie => { const eqPos = cookie.indexOf('='); const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); if (name) { document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;`; } }); } catch (err) { console.warn('[SpringAuth] Failed to clear cookies on sign out', err); } try { await clearPlatformAuthAfterSignOut(); } catch (cleanupError) { console.warn('[SpringAuth] Failed to run platform auth cleanup', cleanupError); } // Notify listeners this.notifyListeners('SIGNED_OUT', null); return { error: null }; } catch (error: unknown) { console.error('[SpringAuth] signOut error:', error); // Still remove token even if backend call fails localStorage.removeItem('stirling_jwt'); try { await clearPlatformAuthAfterSignOut(); } catch (cleanupError) { console.warn('[SpringAuth] Failed to run platform auth cleanup after error', cleanupError); } return { error: { message: getErrorMessage(error, 'Logout failed') }, }; } } /** * Refresh JWT token */ async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> { try { if (await isDesktopSaaSAuthMode()) { const refreshed = await refreshPlatformSession(); if (!refreshed) { localStorage.removeItem('stirling_jwt'); return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' }, }; } const { data, error } = await this.getSession(); if (error || !data.session) { return { data: { session: null }, error: error || { message: 'Token refresh failed - please log in again' }, }; } // Calculate adaptive intervals for desktop SaaS mode const token = localStorage.getItem('stirling_jwt'); if (token) { this.calculateAdaptiveIntervals(token); } this.notifyListeners('TOKEN_REFRESHED', data.session); return { data, error: null }; } const response = await apiClient.post('/api/v1/auth/refresh', null, { headers: { 'X-XSRF-TOKEN': this.getCsrfToken() || '', }, withCredentials: true, suppressErrorToast: true, // Suppress global error handler (we handle errors locally) }); const data = response.data; const token = data.session.access_token; // Update local storage with new token localStorage.setItem('stirling_jwt', token); // Sync token to platform-specific storage (Tauri store for desktop) await savePlatformToken(token); // Calculate adaptive monitoring intervals based on token lifetime this.calculateAdaptiveIntervals(token); 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('TOKEN_REFRESHED', session); console.debug('[SpringAuth] Token refreshed successfully'); return { data: { session }, error: null }; } catch (error: unknown) { console.error('[SpringAuth] refreshSession error:', error); localStorage.removeItem('stirling_jwt'); // Handle different error statuses const status = getHttpStatus(error); if (status === 401 || status === 403) { return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } }; } return { data: { session: null }, error: { message: getErrorMessage(error, 'Token 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 // Interval is adaptive based on token lifetime (calculated when token is received) this.sessionCheckInterval = setInterval(async () => { try { // Try to get current session const { data } = await this.getSession(); // If we have a session, proactively refresh if needed if (data.session) { const timeUntilExpiry = (data.session.expires_at || 0) - Date.now(); // Refresh if token expires soon (threshold is adaptive) if (timeUntilExpiry > 0 && timeUntilExpiry < this.tokenRefreshThresholdMs) { console.log('[SpringAuth] 🔄 Proactively refreshing token (expires in ' + Math.floor(timeUntilExpiry / 1000) + 's)'); await this.refreshSession(); } } } catch (error) { console.error('[SpringAuth] Session monitoring error:', error); } }, this.sessionCheckIntervalMs); } private restartSessionMonitoring() { // Stop existing interval if (this.sessionCheckInterval) { clearInterval(this.sessionCheckInterval); this.sessionCheckInterval = null; } // Start with new interval this.startSessionMonitoring(); } 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;