mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 02:54: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:
@@ -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