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
@@ -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,