Files
Stirling-PDF/frontend/src/proprietary/auth/springAuthClient.ts
T
Anthony StirlingandGitHub 558c75a2b1 JWT enhancements for desktop (#5742)
# Description of Changes

This is temporary solution which will be enhanced in future

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-16 21:57:42 +00:00

749 lines
24 KiB
TypeScript

/**
* 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<string, any>;
}
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<string, unknown> | 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<AuthResponse> {
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<AuthResponse> {
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<string, any> };
}): 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;