mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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.
This commit is contained in:
@@ -1236,9 +1236,21 @@ label = "Enable Key Cleanup"
|
||||
description = "Automatically rotate JWT signing keys periodically"
|
||||
label = "Enable Key Rotation"
|
||||
|
||||
[admin.settings.security.jwt.keyRetentionDays]
|
||||
description = "Number of days to retain old JWT keys for verification"
|
||||
label = "Key Retention Days"
|
||||
[admin.settings.security.jwt.tokenExpiryMinutes]
|
||||
description = "Access token lifetime in minutes for web clients (default: 1440 = 24 hours)"
|
||||
label = "Web Token Expiry (minutes)"
|
||||
|
||||
[admin.settings.security.jwt.desktopTokenExpiryMinutes]
|
||||
description = "Access token lifetime in minutes for desktop clients. Desktop apps automatically detected via User-Agent and receive longer sessions for better UX (default: 43200 = 30 days)"
|
||||
label = "Desktop Token Expiry (minutes)"
|
||||
|
||||
[admin.settings.security.jwt.allowedClockSkewSeconds]
|
||||
description = "Tolerance for client/server time drift during token validation (default: 60 seconds)"
|
||||
label = "Clock Skew Tolerance (seconds)"
|
||||
|
||||
[admin.settings.security.jwt.refreshGraceMinutes]
|
||||
description = "Allow token refresh within this many minutes after expiry (default: 15 minutes, max 3 attempts)"
|
||||
label = "Refresh Grace Period (minutes)"
|
||||
|
||||
[admin.settings.security.jwt.persistence]
|
||||
description = "Store JWT keys persistently to survive server restarts"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.4.6",
|
||||
"version": "2.5.0",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.4.6',
|
||||
appVersion: '2.5.0',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -227,6 +227,10 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
disabled={testing || loading}
|
||||
onClick={() => {
|
||||
setCustomUrl(serverUrl);
|
||||
// Auto-submit the form after setting the URL
|
||||
setTimeout(() => {
|
||||
handleSubmit(new Event('submit') as any);
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
{t('setup.server.useLast', 'Last used server: {{serverUrl}}', { serverUrl: serverUrl })}
|
||||
|
||||
@@ -13,7 +13,17 @@ export async function clearPlatformAuthAfterSignOut(): Promise<void> {
|
||||
|
||||
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
|
||||
try {
|
||||
await authService.localClearAuth();
|
||||
// Only clear if there's NO token in storage
|
||||
// If token exists, user just logged in and we should keep it
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('stirling_jwt') : null;
|
||||
console.log('[AuthCleanup] Login init check - token exists:', !!token, 'length:', token?.length || 0);
|
||||
|
||||
if (!token) {
|
||||
console.log('[AuthCleanup] No token found on login init, clearing stale auth data');
|
||||
await authService.localClearAuth();
|
||||
} else {
|
||||
console.log('[AuthCleanup] Token present on login init (length:', token.length, '), skipping cleanup (fresh login)');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[AuthCleanup] Failed to clear desktop auth data on login init', err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { authService } from '@app/services/authService';
|
||||
import type { PlatformSessionUser } from '@proprietary/extensions/platformSessionBridge';
|
||||
|
||||
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
|
||||
try {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
// Return true for ANY desktop auth mode (SaaS or self-hosted with desktop authService)
|
||||
// This skips redundant backend validation in springAuthClient since desktop authService
|
||||
// already manages the token lifecycle
|
||||
return mode === 'saas' || mode === 'selfhosted';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
|
||||
try {
|
||||
const userInfo = await authService.getUserInfo();
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
username: userInfo.username,
|
||||
email: userInfo.email,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshPlatformSession(): Promise<boolean> {
|
||||
try {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
if (mode === 'saas') {
|
||||
return await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
|
||||
} else if (mode === 'selfhosted') {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (!serverConfig) {
|
||||
return false;
|
||||
}
|
||||
return await authService.refreshToken(serverConfig.url);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save token to platform-specific secure storage (Tauri store + localStorage)
|
||||
* Called after token refresh to ensure token is synced across all storage locations
|
||||
*/
|
||||
export async function savePlatformToken(token: string): Promise<void> {
|
||||
try {
|
||||
await authService.saveToken(token);
|
||||
} catch (error) {
|
||||
console.error('[PlatformBridge] Failed to save token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ let lastBackendToast = 0;
|
||||
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
|
||||
operationName?: string;
|
||||
skipBackendReadyCheck?: boolean;
|
||||
skipAuthRedirect?: boolean;
|
||||
_retry?: boolean;
|
||||
}
|
||||
|
||||
@@ -55,7 +56,10 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// Self-hosted mode: enable credentials for session management
|
||||
extendedConfig.withCredentials = true;
|
||||
|
||||
// If another request is already refreshing, wait before attaching token.
|
||||
await authService.awaitRefreshIfInProgress();
|
||||
const token = await authService.getAuthToken();
|
||||
|
||||
if (token) {
|
||||
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
@@ -104,9 +108,16 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config as ExtendedRequestConfig;
|
||||
const requestUrl = String(originalRequest?.url || '');
|
||||
const isAuthProbeRequest = requestUrl.includes('/api/v1/auth/me');
|
||||
|
||||
// Handle 401 Unauthorized - try to refresh token
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
// `/auth/me` is used as a probe by session bootstrap; refreshing here can
|
||||
// create recursion (refresh -> save token -> jwt-available -> /auth/me).
|
||||
if (isAuthProbeRequest) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
console.warn('[apiClientSetup] 401 on path:', window.location.pathname, 'url:', originalRequest.url);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export class AuthService {
|
||||
private authStatus: AuthStatus = 'unauthenticated';
|
||||
private userInfo: UserInfo | null = null;
|
||||
private cachedToken: string | null = null;
|
||||
private lastTokenSaveTime: number = 0;
|
||||
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
||||
private refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
@@ -52,52 +53,51 @@ export class AuthService {
|
||||
/**
|
||||
* Save token to all storage locations and notify listeners
|
||||
*/
|
||||
private async saveTokenEverywhere(token: string, refreshToken?: string | null): Promise<void> {
|
||||
private async saveTokenEverywhere(
|
||||
token: string,
|
||||
refreshToken?: string | null,
|
||||
emitJwtAvailable = true
|
||||
): Promise<void> {
|
||||
// Validate token before caching
|
||||
if (!token || token.trim().length === 0) {
|
||||
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
console.log(`[Desktop AuthService] Saving token (length: ${token.length})`);
|
||||
|
||||
// Save access token to Tauri secure store (primary)
|
||||
try {
|
||||
await invoke('save_auth_token', { token });
|
||||
console.log('[Desktop AuthService] ✅ Token saved to Tauri store');
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] ❌ Failed to save token to Tauri store:', error);
|
||||
console.error('[Desktop AuthService] Failed to save token to Tauri store:', error);
|
||||
// Don't throw - we can still use localStorage
|
||||
}
|
||||
|
||||
// Sync to localStorage for web layer (fallback)
|
||||
try {
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[Desktop AuthService] ✅ Token saved to localStorage');
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] ❌ Failed to save token to localStorage:', error);
|
||||
console.error('[Desktop AuthService] Failed to save token to localStorage:', error);
|
||||
}
|
||||
|
||||
// Cache the valid token in memory
|
||||
this.cachedToken = token;
|
||||
console.log('[Desktop AuthService] ✅ Token cached in memory');
|
||||
this.lastTokenSaveTime = Date.now();
|
||||
|
||||
// Save refresh token if provided (keyring with Tauri Store fallback)
|
||||
if (refreshToken) {
|
||||
console.log('[Desktop AuthService] Saving refresh token to secure storage...');
|
||||
try {
|
||||
await invoke('save_refresh_token', { token: refreshToken });
|
||||
console.log('[Desktop AuthService] ✅ Refresh token saved to secure storage');
|
||||
// Only remove from localStorage after successful save
|
||||
localStorage.removeItem('stirling_refresh_token');
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] ❌ Failed to save refresh token:', error);
|
||||
console.error('[Desktop AuthService] Failed to save refresh token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify other parts of the system
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
console.log('[Desktop AuthService] Dispatched jwt-available event');
|
||||
if (emitJwtAvailable) {
|
||||
// Notify other parts of the system when a brand-new auth session is established.
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,37 +108,21 @@ export class AuthService {
|
||||
try {
|
||||
const token = await invoke<string | null>('get_auth_token');
|
||||
if (token) {
|
||||
console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
|
||||
return token;
|
||||
}
|
||||
|
||||
console.log('[Desktop AuthService] ℹ️ No token in Tauri store, checking localStorage...');
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] ❌ Failed to read from Tauri store:', error);
|
||||
console.error('[Desktop AuthService] Failed to read from Tauri store:', error);
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
const localStorageToken = localStorage.getItem('stirling_jwt');
|
||||
if (localStorageToken) {
|
||||
console.log(`[Desktop AuthService] ✅ Token found in localStorage (length: ${localStorageToken.length})`);
|
||||
} else {
|
||||
console.log('[Desktop AuthService] ❌ No token found in any storage');
|
||||
}
|
||||
|
||||
return localStorageToken;
|
||||
return localStorage.getItem('stirling_jwt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token from secure storage (keyring or Tauri Store fallback)
|
||||
*/
|
||||
private async getRefreshToken(): Promise<string | null> {
|
||||
const token = await invoke<string | null>('get_refresh_token');
|
||||
if (token) {
|
||||
console.log('[Desktop AuthService] ✅ Refresh token retrieved from secure storage');
|
||||
} else {
|
||||
console.log('[Desktop AuthService] No refresh token in secure storage');
|
||||
}
|
||||
return token;
|
||||
return await invoke<string | null>('get_refresh_token');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,19 +131,16 @@ export class AuthService {
|
||||
private async clearTokenEverywhere(): Promise<void> {
|
||||
// Invalidate cache
|
||||
this.cachedToken = null;
|
||||
console.log('[Desktop AuthService] Cache invalidated');
|
||||
|
||||
// Best effort: clear Tauri keyring (both access and refresh tokens)
|
||||
try {
|
||||
await invoke('clear_auth_token');
|
||||
console.log('[Desktop AuthService] Cleared Tauri keyring access token');
|
||||
} catch (error) {
|
||||
console.warn('[Desktop AuthService] Failed to clear Tauri keyring access token', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('clear_refresh_token');
|
||||
console.log('[Desktop AuthService] Cleared Tauri keyring refresh token');
|
||||
} catch (error) {
|
||||
console.warn('[Desktop AuthService] Failed to clear Tauri keyring refresh token', error);
|
||||
}
|
||||
@@ -168,7 +149,6 @@ export class AuthService {
|
||||
try {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
localStorage.removeItem('stirling_refresh_token');
|
||||
console.log('[Desktop AuthService] Cleared localStorage tokens');
|
||||
} catch (error) {
|
||||
console.warn('[Desktop AuthService] Failed to clear localStorage tokens', error);
|
||||
}
|
||||
@@ -268,24 +248,17 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise<UserInfo> {
|
||||
console.log(`[Desktop AuthService] 🔐 Starting login to: ${serverUrl}`);
|
||||
console.log(`[Desktop AuthService] Username: ${username}`);
|
||||
|
||||
try {
|
||||
// Validate SaaS configuration if connecting to SaaS
|
||||
if (serverUrl === STIRLING_SAAS_URL) {
|
||||
if (!STIRLING_SAAS_URL) {
|
||||
console.error('[Desktop AuthService] ❌ VITE_SAAS_SERVER_URL is not configured');
|
||||
throw new Error('VITE_SAAS_SERVER_URL is not configured');
|
||||
}
|
||||
if (!SUPABASE_KEY) {
|
||||
console.error('[Desktop AuthService] ❌ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
|
||||
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Desktop AuthService] Invoking Rust login command...');
|
||||
|
||||
// Call Rust login command (bypasses CORS)
|
||||
const response = await invoke<LoginResponse>('login', {
|
||||
serverUrl,
|
||||
@@ -298,26 +271,19 @@ export class AuthService {
|
||||
|
||||
const { token, username: returnedUsername, email } = response;
|
||||
|
||||
console.log('[Desktop AuthService] ✅ Login response received');
|
||||
console.log(`[Desktop AuthService] Username from response: ${returnedUsername || username}`);
|
||||
|
||||
// Save token to all storage locations
|
||||
try {
|
||||
console.log('[Desktop AuthService] Saving token to storage...');
|
||||
await this.saveTokenEverywhere(token);
|
||||
console.log('[Desktop AuthService] ✅ Token saved successfully');
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] ❌ Failed to save token:', error);
|
||||
console.error('[Desktop AuthService] Failed to save token:', error);
|
||||
throw new Error('Failed to save authentication token');
|
||||
}
|
||||
|
||||
// Save user info to store
|
||||
console.log('[Desktop AuthService] Saving user info...');
|
||||
await invoke('save_user_info', {
|
||||
username: returnedUsername || username,
|
||||
email,
|
||||
});
|
||||
console.log('[Desktop AuthService] ✅ User info saved');
|
||||
|
||||
const userInfo: UserInfo = {
|
||||
username: returnedUsername || username,
|
||||
@@ -326,10 +292,9 @@ export class AuthService {
|
||||
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
|
||||
console.log('[Desktop AuthService] ✅ Login completed successfully');
|
||||
return userInfo;
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] ❌ Login failed:', error);
|
||||
console.error('[Desktop AuthService] Login failed:', error);
|
||||
|
||||
// Provide more detailed error messages based on the error type
|
||||
if (error instanceof Error || typeof error === 'string') {
|
||||
@@ -338,55 +303,46 @@ export class AuthService {
|
||||
|
||||
if (errMsg.includes('mfa_required')) {
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
console.error('[Desktop AuthService] Two-factor authentication required');
|
||||
throw new AuthServiceError('Two-factor code required.', 'mfa_required');
|
||||
}
|
||||
|
||||
if (errMsg.includes('invalid_mfa_code')) {
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
console.error('[Desktop AuthService] Invalid two-factor code provided');
|
||||
throw new AuthServiceError('Invalid two-factor code.', 'invalid_mfa_code');
|
||||
}
|
||||
|
||||
// Authentication errors
|
||||
if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) {
|
||||
console.error('[Desktop AuthService] Authentication failed - invalid credentials');
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
throw new Error('Invalid username or password. Please check your credentials and try again.');
|
||||
}
|
||||
// Server not found or unreachable
|
||||
else if (errMsg.includes('connection refused') || errMsg.includes('econnrefused')) {
|
||||
console.error('[Desktop AuthService] Server connection refused');
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
throw new Error('Cannot connect to server. Please check the server URL and ensure the server is running.');
|
||||
}
|
||||
// Timeout
|
||||
else if (errMsg.includes('timeout') || errMsg.includes('timed out')) {
|
||||
console.error('[Desktop AuthService] Login request timed out');
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
throw new Error('Login request timed out. Please check your network connection and try again.');
|
||||
}
|
||||
// DNS failure
|
||||
else if (errMsg.includes('getaddrinfo') || errMsg.includes('dns') || errMsg.includes('not found') || errMsg.includes('enotfound')) {
|
||||
console.error('[Desktop AuthService] DNS resolution failed');
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
throw new Error('Cannot resolve server address. Please check the server URL is correct.');
|
||||
}
|
||||
// SSL/TLS errors
|
||||
else if (errMsg.includes('ssl') || errMsg.includes('tls') || errMsg.includes('certificate') || errMsg.includes('cert')) {
|
||||
console.error('[Desktop AuthService] SSL/TLS error');
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
throw new Error('SSL/TLS certificate error. Server may have an invalid or self-signed certificate.');
|
||||
}
|
||||
// 404 - endpoint not found
|
||||
else if (errMsg.includes('404') || errMsg.includes('not found')) {
|
||||
console.error('[Desktop AuthService] Login endpoint not found');
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
throw new Error('Login endpoint not found. Please ensure you are connecting to a valid Stirling PDF server.');
|
||||
}
|
||||
// 403 - security disabled
|
||||
else if (errMsg.includes('403') || errMsg.includes('forbidden')) {
|
||||
console.error('[Desktop AuthService] Login disabled on server');
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
throw new Error('Login is not enabled on this server. Please enable security mode (DOCKER_ENABLE_SECURITY=true).');
|
||||
}
|
||||
@@ -398,10 +354,16 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to save token to all storage locations
|
||||
* Called by springAuthClient after token refresh to sync Tauri store
|
||||
*/
|
||||
async saveToken(token: string): Promise<void> {
|
||||
await this.saveTokenEverywhere(token, undefined, false);
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
console.log('Logging out');
|
||||
|
||||
// Best-effort backend logout so any server-side session/cookies are cleared
|
||||
try {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
|
||||
@@ -444,8 +406,6 @@ export class AuthService {
|
||||
await invoke('clear_user_info');
|
||||
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
|
||||
console.log('Logged out successfully');
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
// Still set status to unauthenticated even if clear fails
|
||||
@@ -457,21 +417,31 @@ export class AuthService {
|
||||
|
||||
async getAuthToken(): Promise<string | null> {
|
||||
try {
|
||||
// Return cached token if available
|
||||
// Check cached token validity before returning
|
||||
if (this.cachedToken) {
|
||||
console.debug('[Desktop AuthService] ✅ Returning cached token');
|
||||
return this.cachedToken;
|
||||
// Use minimal leeway (5s) for cache validation to avoid excessive invalidation
|
||||
// Health checks run every 5s, so 30s leeway would cause 5-6 unnecessary cache clears
|
||||
// The 30s leeway is used elsewhere for proactive refresh before user operations
|
||||
if (this.isTokenExpiringSoon(this.cachedToken, 5)) {
|
||||
console.warn('[Desktop AuthService] ⚠️ Cached token is expired or expiring soon, invalidating cache');
|
||||
this.cachedToken = null;
|
||||
// Fall through to fetch from storage
|
||||
} else {
|
||||
console.debug('[Desktop AuthService] ✅ Returning cached token');
|
||||
return this.cachedToken;
|
||||
}
|
||||
}
|
||||
|
||||
console.debug('[Desktop AuthService] Cache miss, fetching from storage...');
|
||||
const token = await this.getTokenFromAnySource();
|
||||
|
||||
// Cache the token if valid
|
||||
// Cache token if found (backend will validate expiry)
|
||||
if (token && token.trim().length > 0) {
|
||||
this.cachedToken = token;
|
||||
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
|
||||
return token;
|
||||
}
|
||||
return token;
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[Desktop AuthService] Failed to get auth token:', error);
|
||||
return null;
|
||||
@@ -505,6 +475,59 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async awaitRefreshIfInProgress(): Promise<boolean> {
|
||||
if (!this.refreshPromise) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
console.debug('[Desktop AuthService] Waiting for in-flight refresh to complete');
|
||||
return await this.refreshPromise;
|
||||
} catch (error) {
|
||||
console.warn('[Desktop AuthService] In-flight refresh failed while waiting', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isTokenExpiringSoon(token: string, leewaySeconds = 30): boolean {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) {
|
||||
console.warn('[Desktop AuthService] Token malformed - less than 2 parts');
|
||||
return true;
|
||||
}
|
||||
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/')
|
||||
.padEnd(Math.ceil(base64Url.length / 4) * 4, '=');
|
||||
const payload = JSON.parse(atob(base64));
|
||||
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
|
||||
|
||||
if (!expSeconds) {
|
||||
console.warn('[Desktop AuthService] Token has no exp claim');
|
||||
return true;
|
||||
}
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const nowWithLeeway = nowSeconds + Math.max(0, leewaySeconds);
|
||||
const timeUntilExpiry = expSeconds - nowSeconds;
|
||||
const isExpiring = expSeconds <= nowWithLeeway;
|
||||
|
||||
console.debug('[Desktop AuthService] Token expiry check:', {
|
||||
expiresIn: timeUntilExpiry + 's',
|
||||
leeway: leewaySeconds + 's',
|
||||
isExpiring
|
||||
});
|
||||
|
||||
return isExpiring;
|
||||
} catch (err) {
|
||||
// If parsing fails, treat token as unsafe/stale and force refresh path.
|
||||
console.warn('[Desktop AuthService] Token parsing failed:', err);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken(serverUrl: string): Promise<boolean> {
|
||||
// Prevent concurrent refresh attempts - reuse in-flight refresh
|
||||
if (this.refreshPromise) {
|
||||
@@ -542,10 +565,20 @@ export class AuthService {
|
||||
}
|
||||
);
|
||||
|
||||
const { token } = response.data;
|
||||
const token =
|
||||
response.data?.session?.access_token ??
|
||||
response.data?.access_token ??
|
||||
response.data?.token;
|
||||
|
||||
if (!token) {
|
||||
console.error('[Desktop AuthService] Refresh response missing token payload');
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
await this.logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save token to all storage locations
|
||||
await this.saveTokenEverywhere(token);
|
||||
await this.saveTokenEverywhere(token, undefined, false);
|
||||
|
||||
const userInfo = await this.getUserInfo();
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
@@ -607,7 +640,7 @@ export class AuthService {
|
||||
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||
|
||||
// Save new tokens
|
||||
await this.saveTokenEverywhere(access_token, newRefreshToken);
|
||||
await this.saveTokenEverywhere(access_token, newRefreshToken, false);
|
||||
|
||||
const userInfo = await this.getUserInfo();
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
@@ -630,16 +663,28 @@ export class AuthService {
|
||||
// If we are on the login/setup screen, don't auto-restore a previous session; clear instead
|
||||
const path = typeof window !== 'undefined' ? window.location.pathname : '';
|
||||
if (path.startsWith('/login') || path.startsWith('/setup')) {
|
||||
console.log('[Desktop AuthService] On login/setup path, clearing any cached auth');
|
||||
// Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
|
||||
await this.clearTokenEverywhere().catch(() => {});
|
||||
try {
|
||||
await invoke('clear_user_info');
|
||||
} catch (err) {
|
||||
console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err);
|
||||
// Check if token exists in storage (user just logged in via web flow)
|
||||
const tokenInStorage = typeof window !== 'undefined' ? localStorage.getItem('stirling_jwt') : null;
|
||||
if (tokenInStorage) {
|
||||
console.log('[Desktop AuthService] On login/setup path with token present - skipping validation');
|
||||
console.log('[Desktop AuthService] Login flow will handle authentication state');
|
||||
// Return early to avoid clearing partial state during login completion
|
||||
// The login completion handler (completeSelfHostedSession) will:
|
||||
// 1. Fetch and save user info
|
||||
// 2. Set auth status to authenticated
|
||||
return;
|
||||
} else {
|
||||
console.log('[Desktop AuthService] On login/setup path, clearing any cached auth');
|
||||
// Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
|
||||
await this.clearTokenEverywhere().catch(() => {});
|
||||
try {
|
||||
await invoke('clear_user_info');
|
||||
} catch (err) {
|
||||
console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err);
|
||||
}
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
return;
|
||||
}
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await this.getAuthToken();
|
||||
|
||||
@@ -117,20 +117,15 @@ export class TauriBackendService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth token from any available source (localStorage or Tauri store)
|
||||
* Get auth token with expiry validation
|
||||
* Delegates to authService which handles caching and expiry checking
|
||||
*/
|
||||
private async getAuthToken(): Promise<string | null> {
|
||||
// Check localStorage first (web layer token)
|
||||
const localStorageToken = localStorage.getItem('stirling_jwt');
|
||||
if (localStorageToken) {
|
||||
return localStorageToken;
|
||||
}
|
||||
|
||||
// Fallback to Tauri store
|
||||
try {
|
||||
return await invoke<string | null>('get_auth_token');
|
||||
} catch {
|
||||
console.debug('[TauriBackendService] No auth token available');
|
||||
const { authService } = await import('./authService');
|
||||
return await authService.getAuthToken();
|
||||
} catch (error) {
|
||||
console.debug('[TauriBackendService] Failed to get auth token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('SpringAuthClient', () => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${mockToken}` },
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
expect(result.data.session).toBeTruthy();
|
||||
expect(result.data.session?.user).toEqual(mockUser);
|
||||
@@ -309,14 +310,10 @@ describe('SpringAuthClient', () => {
|
||||
},
|
||||
} as any);
|
||||
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
const result = await springAuth.refreshSession();
|
||||
|
||||
expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'jwt-available' })
|
||||
);
|
||||
// Note: refreshSession does not dispatch jwt-available event, only notifies listeners
|
||||
expect(result.data.session?.access_token).toBe(newToken);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
@@ -13,8 +13,29 @@ 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) {
|
||||
@@ -98,14 +119,100 @@ type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => v
|
||||
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
|
||||
|
||||
// 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
|
||||
*/
|
||||
@@ -127,13 +234,54 @@ class SpringAuthClient {
|
||||
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
||||
try {
|
||||
// Get JWT from localStorage
|
||||
const token = localStorage.getItem('stirling_jwt');
|
||||
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');
|
||||
@@ -142,6 +290,8 @@ class SpringAuthClient {
|
||||
'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);
|
||||
@@ -149,11 +299,12 @@ class SpringAuthClient {
|
||||
// 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: 3600,
|
||||
expires_at: Date.now() + 3600 * 1000,
|
||||
expires_in: tokenExpiry.expiresIn,
|
||||
expires_at: tokenExpiry.expiresAt,
|
||||
};
|
||||
|
||||
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
||||
@@ -161,8 +312,15 @@ class SpringAuthClient {
|
||||
} catch (error: unknown) {
|
||||
console.error('[SpringAuth] getSession error:', error);
|
||||
|
||||
// If 401/403, token is invalid - clear it
|
||||
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
|
||||
// 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 };
|
||||
@@ -201,6 +359,12 @@ class SpringAuthClient {
|
||||
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'));
|
||||
|
||||
@@ -382,6 +546,34 @@ class SpringAuthClient {
|
||||
*/
|
||||
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() || '',
|
||||
@@ -396,8 +588,11 @@ class SpringAuthClient {
|
||||
// Update local storage with new token
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
// 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,
|
||||
@@ -417,7 +612,8 @@ class SpringAuthClient {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
|
||||
// Handle different error statuses
|
||||
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
|
||||
const status = getHttpStatus(error);
|
||||
if (status === 401 || status === 403) {
|
||||
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
|
||||
}
|
||||
|
||||
@@ -462,27 +658,36 @@ class SpringAuthClient {
|
||||
|
||||
private startSessionMonitoring() {
|
||||
// Periodically check session validity
|
||||
// Since we use HttpOnly cookies, we just need to check with the server
|
||||
// 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
|
||||
// (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');
|
||||
// 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.SESSION_CHECK_INTERVAL);
|
||||
}, this.sessionCheckIntervalMs);
|
||||
}
|
||||
|
||||
private restartSessionMonitoring() {
|
||||
// Stop existing interval
|
||||
if (this.sessionCheckInterval) {
|
||||
clearInterval(this.sessionCheckInterval);
|
||||
this.sessionCheckInterval = null;
|
||||
}
|
||||
// Start with new interval
|
||||
this.startSessionMonitoring();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
|
||||
+70
-9
@@ -21,7 +21,10 @@ interface SecuritySettingsData {
|
||||
persistence?: boolean;
|
||||
enableKeyRotation?: boolean;
|
||||
enableKeyCleanup?: boolean;
|
||||
keyRetentionDays?: number;
|
||||
tokenExpiryMinutes?: number;
|
||||
desktopTokenExpiryMinutes?: number;
|
||||
allowedClockSkewSeconds?: number;
|
||||
refreshGraceMinutes?: number;
|
||||
secureCookie?: boolean;
|
||||
};
|
||||
audit?: {
|
||||
@@ -131,7 +134,10 @@ export default function AdminSecuritySection() {
|
||||
'security.jwt.persistence': securitySettings.jwt?.persistence,
|
||||
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
|
||||
'security.jwt.enableKeyCleanup': securitySettings.jwt?.enableKeyCleanup,
|
||||
'security.jwt.keyRetentionDays': securitySettings.jwt?.keyRetentionDays,
|
||||
'security.jwt.tokenExpiryMinutes': securitySettings.jwt?.tokenExpiryMinutes,
|
||||
'security.jwt.desktopTokenExpiryMinutes': securitySettings.jwt?.desktopTokenExpiryMinutes,
|
||||
'security.jwt.allowedClockSkewSeconds': securitySettings.jwt?.allowedClockSkewSeconds,
|
||||
'security.jwt.refreshGraceMinutes': securitySettings.jwt?.refreshGraceMinutes,
|
||||
'security.jwt.secureCookie': securitySettings.jwt?.secureCookie,
|
||||
// Premium audit settings
|
||||
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
|
||||
@@ -382,20 +388,75 @@ export default function AdminSecuritySection() {
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
name="jwt_keyRetentionDays"
|
||||
name="jwt_tokenExpiryMinutes"
|
||||
label={
|
||||
<Group component="span" gap="xs">
|
||||
<span>{t('admin.settings.security.jwt.keyRetentionDays.label', 'Key Retention Days')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.keyRetentionDays')} />
|
||||
<span>{t('admin.settings.security.jwt.tokenExpiryMinutes.label', 'Web Token Expiry (minutes)')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.tokenExpiryMinutes')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
|
||||
value={settings?.jwt?.keyRetentionDays || 7}
|
||||
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, keyRetentionDays: Number(value) } })}
|
||||
description={t('admin.settings.security.jwt.tokenExpiryMinutes.description', 'Access token lifetime in minutes for web clients (default: 1440 = 24 hours)')}
|
||||
value={settings?.jwt?.tokenExpiryMinutes || 1440}
|
||||
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, tokenExpiryMinutes: Number(value) } })}
|
||||
min={1}
|
||||
max={365}
|
||||
max={43200}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
name="jwt_desktopTokenExpiryMinutes"
|
||||
label={
|
||||
<Group component="span" gap="xs">
|
||||
<span>{t('admin.settings.security.jwt.desktopTokenExpiryMinutes.label', 'Desktop Token Expiry (minutes)')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.desktopTokenExpiryMinutes')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.jwt.desktopTokenExpiryMinutes.description', 'Access token lifetime in minutes for desktop clients. Desktop apps automatically detected via User-Agent and receive longer sessions for better UX (default: 43200 = 30 days)')}
|
||||
value={settings?.jwt?.desktopTokenExpiryMinutes || 43200}
|
||||
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, desktopTokenExpiryMinutes: Number(value) } })}
|
||||
min={1}
|
||||
max={525600}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
name="jwt_allowedClockSkewSeconds"
|
||||
label={
|
||||
<Group component="span" gap="xs">
|
||||
<span>{t('admin.settings.security.jwt.allowedClockSkewSeconds.label', 'Clock Skew Tolerance (seconds)')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.allowedClockSkewSeconds')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.jwt.allowedClockSkewSeconds.description', 'Tolerance for client/server time drift during token validation (default: 60 seconds)')}
|
||||
value={settings?.jwt?.allowedClockSkewSeconds ?? 60}
|
||||
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, allowedClockSkewSeconds: Number(value) } })}
|
||||
min={0}
|
||||
max={300}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
name="jwt_refreshGraceMinutes"
|
||||
label={
|
||||
<Group component="span" gap="xs">
|
||||
<span>{t('admin.settings.security.jwt.refreshGraceMinutes.label', 'Refresh Grace Period (minutes)')}</span>
|
||||
<PendingBadge show={isFieldPending('jwt.refreshGraceMinutes')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.jwt.refreshGraceMinutes.description', 'Allow token refresh within this many minutes after expiry (default: 15 minutes, max 3 attempts)')}
|
||||
value={settings?.jwt?.refreshGraceMinutes ?? 15}
|
||||
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, refreshGraceMinutes: Number(value) } })}
|
||||
min={0}
|
||||
max={120}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface PlatformSessionUser {
|
||||
username: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no desktop SaaS auth bridge.
|
||||
*/
|
||||
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no platform user store.
|
||||
*/
|
||||
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no platform refresh path.
|
||||
*/
|
||||
export async function refreshPlatformSession(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no platform-specific token storage (uses localStorage only).
|
||||
*/
|
||||
export async function savePlatformToken(_token: string): Promise<void> {
|
||||
// Web mode: token already saved to localStorage in springAuthClient
|
||||
// No additional platform storage needed
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
}> = [];
|
||||
|
||||
function getJwtTokenFromStorage(): string | null {
|
||||
try {
|
||||
@@ -9,6 +15,24 @@ function getJwtTokenFromStorage(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function setJwtTokenInStorage(token: string): void {
|
||||
try {
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.debug('[API Client] Stored new JWT token in localStorage');
|
||||
} catch (error) {
|
||||
console.error('[API Client] Failed to store JWT in localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function clearJwtTokenFromStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
console.debug('[API Client] Cleared JWT token from localStorage');
|
||||
} catch (error) {
|
||||
console.error('[API Client] Failed to clear JWT from localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getXsrfToken(): string | null {
|
||||
try {
|
||||
const cookies = document.cookie.split(';');
|
||||
@@ -25,6 +49,48 @@ function getXsrfToken(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function processQueue(error: Error | null, token: string | null = null): void {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else if (token) {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
async function refreshAuthToken(client: AxiosInstance): Promise<string> {
|
||||
console.log('[API Client] Refreshing expired JWT token...');
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/v1/auth/refresh', {}, {
|
||||
// Don't retry refresh requests to avoid infinite loops
|
||||
headers: { 'X-Skip-Auth-Refresh': 'true' }
|
||||
});
|
||||
|
||||
const newToken = response.data?.session?.access_token;
|
||||
if (!newToken) {
|
||||
throw new Error('No access token in refresh response');
|
||||
}
|
||||
|
||||
setJwtTokenInStorage(newToken);
|
||||
console.log('[API Client] ✅ Token refreshed successfully');
|
||||
return newToken;
|
||||
} catch (error) {
|
||||
console.error('[API Client] ❌ Token refresh failed:', error);
|
||||
clearJwtTokenFromStorage();
|
||||
|
||||
// Redirect to login
|
||||
if (window.location.pathname !== '/login') {
|
||||
console.log('[API Client] Redirecting to login page...');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// Install request interceptor to add JWT token
|
||||
client.interceptors.request.use(
|
||||
@@ -47,4 +113,61 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Install response interceptor to handle 401 and auto-refresh token
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Skip refresh for auth endpoints or if explicitly disabled
|
||||
// Exception: /auth/me should trigger refresh (used by getSession)
|
||||
if (
|
||||
!originalRequest ||
|
||||
(originalRequest.url?.includes('/api/v1/auth/') && !originalRequest.url?.includes('/api/v1/auth/me')) ||
|
||||
originalRequest.headers?.['X-Skip-Auth-Refresh'] ||
|
||||
originalRequest._retry
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Handle 401 errors by attempting token refresh
|
||||
if (error.response?.status === 401 && getJwtTokenFromStorage()) {
|
||||
console.warn('[API Client] Received 401 error, attempting token refresh...');
|
||||
|
||||
if (isRefreshing) {
|
||||
// Already refreshing - queue this request
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return client(originalRequest);
|
||||
})
|
||||
.catch((err) => {
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshAuthToken(client);
|
||||
processQueue(null, newToken);
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return client(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError as Error, null);
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.4.6',
|
||||
appVersion: '2.5.0',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
enableDesktopInstallSlide: true,
|
||||
|
||||
Reference in New Issue
Block a user