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:
Anthony Stirling
2026-02-16 21:57:42 +00:00
committed by GitHub
parent da2eb54fe8
commit 558c75a2b1
34 changed files with 1767 additions and 214 deletions
+15 -3
View File
@@ -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 -1
View File
@@ -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);
}
+129 -84
View File
@@ -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();
});
+221 -16
View File
@@ -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() {
@@ -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,