From 558c75a2b19f2bebd62abfbdc8d855c2c899fc9a Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:57:42 +0000 Subject: [PATCH] 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. --- .../common/constants/JwtConstants.java | 49 ++++ .../common/model/ApplicationProperties.java | 98 +++++++- .../service/PdfJsonFallbackFontService.java | 3 +- .../src/main/resources/settings.yml.template | 5 +- .../security/configuration/CacheConfig.java | 13 +- .../configuration/SecurityConfiguration.java | 3 +- .../controller/api/AuthController.java | 218 +++++++++++++++- ...tomOAuth2AuthenticationSuccessHandler.java | 26 +- ...stomSaml2AuthenticationSuccessHandler.java | 26 +- .../security/service/JwtService.java | 160 ++++++++++-- .../security/service/JwtServiceInterface.java | 28 +++ .../service/KeyPersistenceService.java | 204 ++++++++++++++- .../service/RefreshRateLimitService.java | 124 +++++++++ .../security/util/DesktopClientUtils.java | 82 ++++++ .../api/AuthControllerLoginTest.java | 89 ++++++- ...Auth2AuthenticationSuccessHandlerTest.java | 8 +- .../security/service/JwtServiceTest.java | 18 +- build.gradle | 2 +- .../public/locales/en-GB/translation.toml | 18 +- frontend/src-tauri/tauri.conf.json | 2 +- .../testing/serverExperienceSimulations.ts | 2 +- .../SetupWizard/ServerSelection.tsx | 4 + .../desktop/extensions/authSessionCleanup.ts | 12 +- .../extensions/platformSessionBridge.ts | 62 +++++ .../src/desktop/services/apiClientSetup.ts | 11 + frontend/src/desktop/services/authService.ts | 213 +++++++++------- .../desktop/services/tauriBackendService.ts | 17 +- .../proprietary/auth/springAuthClient.test.ts | 7 +- .../src/proprietary/auth/springAuthClient.ts | 237 ++++++++++++++++-- .../configSections/AdminSecuritySection.tsx | 79 +++++- .../extensions/platformSessionBridge.ts | 33 +++ .../proprietary/services/apiClientSetup.ts | 125 ++++++++- .../testing/serverExperienceSimulations.ts | 2 +- testing/allEndpointsRemovedSettings.yml | 1 - 34 files changed, 1767 insertions(+), 214 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/constants/JwtConstants.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/util/DesktopClientUtils.java create mode 100644 frontend/src/desktop/extensions/platformSessionBridge.ts create mode 100644 frontend/src/proprietary/extensions/platformSessionBridge.ts diff --git a/app/common/src/main/java/stirling/software/common/constants/JwtConstants.java b/app/common/src/main/java/stirling/software/common/constants/JwtConstants.java new file mode 100644 index 000000000..a05df9bde --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/constants/JwtConstants.java @@ -0,0 +1,49 @@ +package stirling.software.common.constants; + +/** + * Centralized constants for JWT token management. + * + *

These defaults are used when configuration values are not explicitly set. + */ +public final class JwtConstants { + + private JwtConstants() { + throw new UnsupportedOperationException("Utility class"); + } + + /** Default JWT access token lifetime in minutes (24 hours). */ + public static final int DEFAULT_TOKEN_EXPIRY_MINUTES = 1440; + + /** Default desktop client token lifetime in minutes (30 days). */ + public static final int DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES = 43200; + + /** + * Default refresh grace period in minutes. + * + *

Allows refresh of expired tokens within this window after expiration. + */ + public static final int DEFAULT_REFRESH_GRACE_MINUTES = 15; + + /** + * Default allowed clock skew in seconds. + * + *

Tolerates small time drift between client and server clocks during validation. + */ + public static final int DEFAULT_CLOCK_SKEW_SECONDS = 60; + + /** Milliseconds per minute. */ + public static final long MILLIS_PER_MINUTE = 60_000L; + + /** Seconds per minute. */ + public static final long SECONDS_PER_MINUTE = 60L; + + /** JWT issuer identifier. */ + public static final String ISSUER = "https://stirling.com"; + + /** + * Maximum refresh attempts allowed within the grace period window. + * + *

Prevents abuse of expired tokens by limiting refresh attempts. + */ + public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3; +} diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index c6988098c..d0c369195 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -39,6 +39,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.YamlPropertySourceFactory; +import stirling.software.common.constants.JwtConstants; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.model.oauth2.GitHubProvider; import stirling.software.common.model.oauth2.GoogleProvider; @@ -393,12 +394,107 @@ public class ApplicationProperties { } } + /** + * JWT token configuration. + * + *

BREAKING CHANGE (v2.0): Default token expiry increased from 12 hours (720 + * minutes) to 24 hours (1440 minutes). If you require the previous behavior, explicitly set + * {@code tokenExpiryMinutes: 720} in your configuration. + */ @Data public static class Jwt { private boolean enableKeystore = true; private boolean enableKeyRotation = false; private boolean enableKeyCleanup = true; - private int keyRetentionDays = 7; + + /** + * JWT access token lifetime in minutes for web clients. + * + *

Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours). + * + *

BREAKING CHANGE: Previously hardcoded to 720 minutes (12 hours). Now + * defaults to 1440 minutes (24 hours). + */ + private int tokenExpiryMinutes = JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES; + + /** + * JWT access token lifetime in minutes for desktop clients (Tauri app). + * + *

Desktop clients are automatically detected via User-Agent header and receive + * longer-lived tokens because they run on personal devices with OS-level encrypted + * storage (macOS Keychain, Windows Credential Manager, Linux Secret Service). + * + *

This provides better UX (login once per month) while maintaining security through + * device encryption and secure storage, matching the behavior of popular desktop apps + * like Slack, Discord, VS Code, etc. + * + *

Default: 43200 minutes (30 days). + */ + private int desktopTokenExpiryMinutes = 43200; + + /** + * Allowed clock skew in seconds for JWT validation. + * + *

Tolerates small time drift between client and server clocks. Tokens that are + * slightly expired or slightly in the future (within this window) will still be + * accepted. + * + *

Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds. + */ + private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS; + + /** + * Grace period in minutes for refreshing expired tokens. + * + *

Allows token refresh using an expired access token if the token expired within + * this many minutes. This provides better UX by allowing users to refresh slightly + * expired tokens without re-authentication. + * + *

Rate limiting is applied to prevent abuse of expired tokens within the grace + * window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts). + * + *

Default: {@value JwtConstants#DEFAULT_REFRESH_GRACE_MINUTES} minutes. + */ + private int refreshGraceMinutes = JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES; + + /** + * Calculate number of days to retain old JWT signing keys. + * + *

Automatically calculated based on the longest token lifetime plus a proportional + * safety buffer. Keys must be retained for at least as long as the tokens they signed + * remain valid, otherwise token verification will fail. + * + *

Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440) + * + *

The buffer includes: + * + *

+ * + * @return calculated key retention period in days + */ + public int getKeyRetentionDays() { + final int MINUTES_PER_DAY = 1440; + final double BUFFER_PERCENTAGE = 0.10; // 10% buffer + + int maxTokenExpiryMinutes = Math.max(tokenExpiryMinutes, desktopTokenExpiryMinutes); + + // Add 10% buffer (scales with token lifetime) + int bufferMinutes = (int) Math.ceil(maxTokenExpiryMinutes * BUFFER_PERCENTAGE); + + // Add refresh grace period + bufferMinutes += refreshGraceMinutes; + + // Add clock skew (convert seconds to minutes, round up) + bufferMinutes += (int) Math.ceil(allowedClockSkewSeconds / 60.0); + + // Total retention in minutes, convert to days (round up) + int totalMinutes = maxTokenExpiryMinutes + bufferMinutes; + return (int) Math.ceil(totalMinutes / (double) MINUTES_PER_DAY); + } } @Data diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java index 7bd56e700..be51dd74e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java @@ -426,7 +426,8 @@ public class PdfJsonFallbackFontService { String normalized = WHITESPACE_PATTERN .matcher( - PATTERN.matcher(originalFontName).replaceAll("") // Remove subset prefix + PATTERN.matcher(originalFontName) + .replaceAll("") // Remove subset prefix .toLowerCase()) .replaceAll(""); // Remove spaces (e.g. "Times New Roman" -> // "timesnewroman") diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index a15da437d..53c252e2d 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -64,7 +64,10 @@ security: persistence: true # Set to 'true' to enable JWT key store enableKeyRotation: true # Set to 'true' to enable key pair rotation enableKeyCleanup: true # Set to 'true' to enable key pair cleanup - keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days. + tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day). + desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days). + allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift. + refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry. validation: # PDF signature validation settings trust: serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java index ba074a5da..501826a08 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java @@ -2,7 +2,7 @@ package stirling.software.proprietary.security.configuration; import java.time.Duration; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.caffeine.CaffeineCacheManager; @@ -11,15 +11,22 @@ import org.springframework.context.annotation.Configuration; import com.github.benmanes.caffeine.cache.Caffeine; +import stirling.software.common.model.ApplicationProperties; + @Configuration @EnableCaching public class CacheConfig { - @Value("${security.jwt.keyRetentionDays}") - private int keyRetentionDays; + private final ApplicationProperties applicationProperties; + + @Autowired + public CacheConfig(ApplicationProperties applicationProperties) { + this.applicationProperties = applicationProperties; + } @Bean public CacheManager cacheManager() { + int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays(); CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.setCaffeine( Caffeine.newBuilder() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 06efcf3a1..f3a8f25b5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -361,7 +361,8 @@ public class SecurityConfiguration { securityProperties.getOauth2(), userService, jwtService, - licenseSettingsService)) + licenseSettingsService, + applicationProperties)) .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) // Add existing Authorities from the database .userInfoEndpoint( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index c7ebdbdb4..62b5f5547 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -26,6 +26,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.constants.JwtConstants; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; @@ -34,12 +35,15 @@ import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.api.user.MfaCodeRequest; import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa; +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.MfaService; +import stirling.software.proprietary.security.service.RefreshRateLimitService; import stirling.software.proprietary.security.service.TotpService; import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.security.util.DesktopClientUtils; /** REST API Controller for authentication operations. */ @RestController @@ -55,7 +59,9 @@ public class AuthController { private final LoginAttemptService loginAttemptService; private final MfaService mfaService; private final TotpService totpService; + private final RefreshRateLimitService refreshRateLimitService; private final ApplicationProperties.Security securityProperties; + private final ApplicationProperties applicationProperties; /** * Login endpoint - replaces Supabase signInWithPassword @@ -171,16 +177,52 @@ public class AuthController { claims.put("authType", AuthenticationType.WEB.toString()); claims.put("role", user.getRolesAsString()); - String token = jwtService.generateToken(user.getUsername(), claims); + // Detect desktop client and issue longer-lived tokens for better UX + // Desktop apps run on personal devices with OS-level encryption (secure storage) + boolean isDesktopClient = DesktopClientUtils.isDesktopClient(httpRequest); + String token; + int keyRetentionDays = securityProperties.getJwt().getKeyRetentionDays(); + if (isDesktopClient) { + // Desktop: Use configured desktop token expiry (default 30 days) + int desktopExpiryMinutes = + DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties); + token = jwtService.generateToken(user.getUsername(), claims, desktopExpiryMinutes); + log.info( + "Issued DESKTOP token for user '{}': expiry={}min ({}d), keyRetention={}d", + username, + desktopExpiryMinutes, + desktopExpiryMinutes / 1440, + keyRetentionDays); + } else { + // Web: Use configured web expiry (default 24 hours) + token = jwtService.generateToken(user.getUsername(), claims); + int webExpiryMinutes = + DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties); + log.info( + "Issued WEB token for user '{}': expiry={}min ({}d), keyRetention={}d", + username, + webExpiryMinutes, + webExpiryMinutes / 1440, + keyRetentionDays); + } // Record successful login loginAttemptService.loginSucceeded(username); - log.info("Login successful for user: {} from IP: {}", username, ip); + log.info( + "Login successful for user: {} from IP: {} (desktop: {})", + username, + ip, + isDesktopClient); return ResponseEntity.ok( Map.of( "user", buildUserResponse(user), - "session", Map.of("access_token", token, "expires_in", 3600))); + "session", + Map.of( + "access_token", + token, + "expires_in", + getTokenExpirySeconds(isDesktopClient)))); } catch (UsernameNotFoundException e) { String username = request.getUsername(); @@ -272,25 +314,92 @@ public class AuthController { .body(Map.of("error", "No token found")); } - jwtService.validateToken(token); - String username = jwtService.extractUsername(token); + // Generate token hash for rate limiting (avoid storing actual tokens) + String tokenHash = generateTokenHash(token); + + Map claims = jwtService.extractClaimsAllowExpired(token); + if (!isRefreshWithinGrace(claims)) { + log.warn("Token refresh rejected: token expired beyond configured grace window"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Token refresh failed")); + } + + // Only apply rate limiting if token is actually expired (not for valid tokens) + // This prevents false-positive 429 errors with multiple tabs, retries, etc. + long expMillis = extractEpochMillis(claims.get("exp")); + boolean isExpired = expMillis > 0 && expMillis < System.currentTimeMillis(); + if (isExpired + && !refreshRateLimitService.isRefreshAllowed( + tokenHash, getRefreshGraceMillis())) { + log.warn( + "Token refresh rejected: rate limit exceeded (max {} attempts allowed)", + JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE); + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body( + Map.of( + "error", + "Too many refresh attempts", + "max_attempts", + JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE)); + } + + Object usernameClaim = claims.get("sub"); + String username = usernameClaim != null ? usernameClaim.toString() : null; + if (username == null || username.isBlank()) { + log.warn("Token refresh rejected: missing subject claim"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Token refresh failed")); + } UserDetails userDetails = userDetailsService.loadUserByUsername(username); User user = (User) userDetails; - Map claims = new HashMap<>(); - claims.put("authType", user.getAuthenticationType()); - claims.put("role", user.getRolesAsString()); + Map newClaims = new HashMap<>(); + newClaims.put("authType", user.getAuthenticationType()); + newClaims.put("role", user.getRolesAsString()); - String newToken = jwtService.generateToken(username, claims); + // Detect desktop client and issue longer-lived tokens + boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request); + String newToken; + if (isDesktopClient) { + int desktopExpiryMinutes = + DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties); + newToken = jwtService.generateToken(username, newClaims, desktopExpiryMinutes); + log.info( + "Refreshed DESKTOP token for user '{}': expiry={}min ({}d)", + username, + desktopExpiryMinutes, + desktopExpiryMinutes / 1440); + } else { + newToken = jwtService.generateToken(username, newClaims); + int webExpiryMinutes = + DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties); + log.info( + "Refreshed WEB token for user '{}': expiry={}min ({}d)", + username, + webExpiryMinutes, + webExpiryMinutes / 1440); + } + + // Don't clear rate limit tracking - let it expire naturally after grace period + // This prevents reusing the same expired token indefinitely log.debug("Token refreshed for user: {}", username); return ResponseEntity.ok( Map.of( "user", buildUserResponse(user), - "session", Map.of("access_token", newToken, "expires_in", 3600))); + "session", + Map.of( + "access_token", + newToken, + "expires_in", + getTokenExpirySeconds(isDesktopClient)))); + } catch (AuthenticationFailureException e) { + log.warn("Token refresh failed: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Token refresh failed")); } catch (Exception e) { log.error("Token refresh error", e); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) @@ -532,6 +641,95 @@ public class AuthController { return userMap; } + private long getTokenExpirySeconds() { + int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes(); + int expiryMinutes = + configuredMinutes > 0 + ? configuredMinutes + : JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES; + return expiryMinutes * JwtConstants.SECONDS_PER_MINUTE; + } + + private long getTokenExpirySeconds(boolean isDesktop) { + if (isDesktop) { + // Desktop: use configured desktop token expiry + return DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties) + * JwtConstants.SECONDS_PER_MINUTE; + } + // Web: use configured web value + return getTokenExpirySeconds(); + } + + private boolean isRefreshWithinGrace(Map claims) { + long expMillis = extractEpochMillis(claims.get("exp")); + if (expMillis <= 0) { + return false; + } + + long now = System.currentTimeMillis(); + if (expMillis >= now) { + return true; + } + + long expiredForMillis = now - expMillis; + return expiredForMillis <= getRefreshGraceMillis(); + } + + private long getRefreshGraceMillis() { + int configuredMinutes = securityProperties.getJwt().getRefreshGraceMinutes(); + int graceMinutes = + configuredMinutes >= 0 + ? configuredMinutes + : JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES; + return graceMinutes * JwtConstants.MILLIS_PER_MINUTE; + } + + private long extractEpochMillis(Object claimValue) { + if (claimValue == null) { + return -1L; + } + + if (claimValue instanceof java.util.Date date) { + return date.getTime(); + } + + if (claimValue instanceof Number number) { + long epochSeconds = number.longValue(); + return epochSeconds * 1000L; + } + + return -1L; + } + + /** + * Generate a hash of the token for rate limiting purposes. + * + *

Uses SHA-256 to avoid storing actual token values in memory. + * + * @param token the JWT token + * @return hex-encoded SHA-256 hash of the token + */ + private String generateTokenHash(String token) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = + digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + // Fallback to hashCode if SHA-256 is not available (should never happen) + log.warn("SHA-256 not available, using hashCode for token tracking", e); + return String.valueOf(token.hashCode()); + } + } + private ResponseEntity ensureWebAuth(User user) { if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) { return ResponseEntity.status(HttpStatus.FORBIDDEN) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 08332dd4b..e86857d33 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -36,6 +36,7 @@ import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.security.util.DesktopClientUtils; @Slf4j @RequiredArgsConstructor @@ -48,6 +49,7 @@ public class CustomOAuth2AuthenticationSuccessHandler private final JwtServiceInterface jwtService; private final stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService; + private final ApplicationProperties applicationProperties; @Override @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) @@ -150,9 +152,27 @@ public class CustomOAuth2AuthenticationSuccessHandler // Generate JWT if v2 is enabled if (jwtService.isJwtEnabled()) { - String jwt = - jwtService.generateToken( - authentication, Map.of("authType", AuthenticationType.OAUTH2)); + Map claims = Map.of("authType", AuthenticationType.OAUTH2); + + // Detect desktop client and issue longer-lived tokens + boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request); + String jwt; + if (isDesktopClient) { + // Desktop: Use configured desktop token expiry (default 30 days) + int desktopExpiryMinutes = + DesktopClientUtils.getDesktopTokenExpiryMinutes( + applicationProperties); + jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes); + log.info( + "Issued DESKTOP OAuth2 token for user '{}': expiry={}min ({}d)", + username, + desktopExpiryMinutes, + desktopExpiryMinutes / 1440); + } else { + // Web: Use default expiry + jwt = jwtService.generateToken(authentication, claims); + log.debug("Issued WEB OAuth2 token for user '{}'", username); + } // Build context-aware redirect URL based on the original request String redirectUrl = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 8076829ec..f790cbac3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -37,6 +37,7 @@ import stirling.software.proprietary.security.oauth2.TauriOAuthUtils; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.security.util.DesktopClientUtils; @AllArgsConstructor @Slf4j @@ -191,10 +192,27 @@ public class CustomSaml2AuthenticationSuccessHandler // Generate JWT if v2 is enabled if (jwtService.isJwtEnabled()) { - String jwt = - jwtService.generateToken( - authentication, - Map.of("authType", AuthenticationType.SAML2)); + Map claims = Map.of("authType", AuthenticationType.SAML2); + + // Detect desktop client and issue longer-lived tokens + boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request); + String jwt; + if (isDesktopClient) { + // Desktop: Use configured desktop token expiry (default 30 days) + int desktopExpiryMinutes = + DesktopClientUtils.getDesktopTokenExpiryMinutes( + applicationProperties); + jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes); + log.info( + "Issued DESKTOP SAML token for user '{}': expiry={}min ({}d)", + username, + desktopExpiryMinutes, + desktopExpiryMinutes / 1440); + } else { + // Web: Use default expiry + jwt = jwtService.generateToken(authentication, claims); + log.debug("Issued WEB SAML token for user '{}'", username); + } // Build context-aware redirect URL based on the original request String redirectUrl = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 60472fef4..a551ab907 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -5,6 +5,7 @@ import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.spec.InvalidKeySpecException; import java.time.LocalDateTime; +import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -19,6 +20,9 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -30,6 +34,8 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.constants.JwtConstants; +import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.model.JwtVerificationKey; import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; @@ -38,18 +44,20 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin @Service public class JwtService implements JwtServiceInterface { - private static final String ISSUER = "https://stirling.com"; - private static final long EXPIRATION = 43200000; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final KeyPersistenceServiceInterface keyPersistenceService; private final boolean v2Enabled; + private final ApplicationProperties.Security securityProperties; @Autowired public JwtService( @Qualifier("v2Enabled") boolean v2Enabled, - KeyPersistenceServiceInterface keyPersistenceService) { + KeyPersistenceServiceInterface keyPersistenceService, + ApplicationProperties applicationProperties) { this.v2Enabled = v2Enabled; this.keyPersistenceService = keyPersistenceService; + this.securityProperties = applicationProperties.getSecurity(); } @Override @@ -84,9 +92,10 @@ public class JwtService implements JwtServiceInterface { Jwts.builder() .claims(claims) .subject(username) - .issuer(ISSUER) + .issuer(JwtConstants.ISSUER) .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) + .expiration( + new Date(System.currentTimeMillis() + getExpirationMillis())) .signWith(keyPair.getPrivate(), Jwts.SIG.RS256); String keyId = activeKey.getKeyId(); @@ -100,6 +109,40 @@ public class JwtService implements JwtServiceInterface { } } + @Override + public String generateToken(String username, Map claims, int expiryMinutes) { + try { + JwtVerificationKey activeKey = keyPersistenceService.getActiveKey(); + Optional keyPairOpt = keyPersistenceService.getKeyPair(activeKey.getKeyId()); + + if (keyPairOpt.isEmpty()) { + throw new RuntimeException("Unable to retrieve key pair for active key"); + } + + KeyPair keyPair = keyPairOpt.get(); + long customExpirationMillis = expiryMinutes * JwtConstants.MILLIS_PER_MINUTE; + + var builder = + Jwts.builder() + .claims(claims) + .subject(username) + .issuer(JwtConstants.ISSUER) + .issuedAt(new Date()) + .expiration( + new Date(System.currentTimeMillis() + customExpirationMillis)) + .signWith(keyPair.getPrivate(), Jwts.SIG.RS256); + + String keyId = activeKey.getKeyId(); + if (keyId != null) { + builder.header().keyId(keyId); + } + + return builder.compact(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate token with custom expiry", e); + } + } + @Override public void validateToken(String token) throws AuthenticationFailureException { extractAllClaims(token); @@ -114,12 +157,23 @@ public class JwtService implements JwtServiceInterface { return extractClaim(token, Claims::getSubject); } + @Override + public String extractUsernameAllowExpired(String token) { + return extractClaim(token, Claims::getSubject, true); + } + @Override public Map extractClaims(String token) { Claims claims = extractAllClaims(token); return new HashMap<>(claims); } + @Override + public Map extractClaimsAllowExpired(String token) { + Claims claims = extractAllClaims(token, true); + return new HashMap<>(claims); + } + @Override public boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); @@ -130,11 +184,21 @@ public class JwtService implements JwtServiceInterface { } private T extractClaim(String token, Function claimsResolver) { - final Claims claims = extractAllClaims(token); + final Claims claims = extractAllClaims(token, false); + return claimsResolver.apply(claims); + } + + private T extractClaim( + String token, Function claimsResolver, boolean allowExpired) { + final Claims claims = extractAllClaims(token, allowExpired); return claimsResolver.apply(claims); } private Claims extractAllClaims(String token) { + return extractAllClaims(token, false); + } + + private Claims extractAllClaims(String token, boolean allowExpired) { try { String keyId = extractKeyId(token); KeyPair keyPair; @@ -176,11 +240,12 @@ public class JwtService implements JwtServiceInterface { } else { log.debug("No key ID in token header, trying all available keys"); // Try all available keys when no keyId is present - return tryAllKeys(token); + return tryAllKeys(token, allowExpired); } return Jwts.parser() .verifyWith(keyPair.getPublic()) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); @@ -191,7 +256,13 @@ public class JwtService implements JwtServiceInterface { log.warn("Invalid token: {}", e.getMessage()); throw new AuthenticationFailureException("Invalid token", e); } catch (ExpiredJwtException e) { - log.warn("The token has expired: {}", e.getMessage()); + if (allowExpired) { + log.debug( + "Extracting claims from expired token (allowed for refresh grace period): {}", + e.getMessage()); + return e.getClaims(); + } + log.warn("Token validation failed - token has expired: {}", e.getMessage()); throw new AuthenticationFailureException("The token has expired", e); } catch (UnsupportedJwtException e) { log.warn("The token is unsupported: {}", e.getMessage()); @@ -202,7 +273,8 @@ public class JwtService implements JwtServiceInterface { } } - private Claims tryAllKeys(String token) throws AuthenticationFailureException { + private Claims tryAllKeys(String token, boolean allowExpired) + throws AuthenticationFailureException { // First try the active key try { JwtVerificationKey activeKey = keyPersistenceService.getActiveKey(); @@ -210,9 +282,18 @@ public class JwtService implements JwtServiceInterface { keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey()); return Jwts.parser() .verifyWith(publicKey) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); + } catch (ExpiredJwtException e) { + if (allowExpired) { + log.debug( + "Extracting claims from expired token (allowed for refresh grace period)"); + return e.getClaims(); + } + log.warn("Token validation failed - token has expired"); + throw new AuthenticationFailureException("The token has expired", e); } catch (SignatureException | NoSuchAlgorithmException | InvalidKeySpecException activeKeyException) { @@ -230,9 +311,15 @@ public class JwtService implements JwtServiceInterface { verificationKey.getVerifyingKey()); return Jwts.parser() .verifyWith(publicKey) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); + } catch (ExpiredJwtException e) { + if (allowExpired) { + return e.getClaims(); + } + throw new AuthenticationFailureException("The token has expired", e); } catch (SignatureException | NoSuchAlgorithmException | InvalidKeySpecException e) { @@ -266,24 +353,51 @@ public class JwtService implements JwtServiceInterface { return v2Enabled; } + /** + * Extract key ID from JWT header without validating the token. + * + *

Parses the Base64-encoded JWT header to retrieve the "kid" (key ID) claim. Returns null if + * the header cannot be parsed or does not contain a key ID. + * + * @param token the JWT token + * @return the key ID, or null if not found or parsing fails + */ private String extractKeyId(String token) { try { - PublicKey signingKey = - keyPersistenceService.decodePublicKey( - keyPersistenceService.getActiveKey().getVerifyingKey()); + String[] tokenParts = token.split("\\."); + if (tokenParts.length < 2) { + log.debug( + "Token does not have enough parts (expected at least 2, got {})", + tokenParts.length); + return null; + } - String keyId = - (String) - Jwts.parser() - .verifyWith(signingKey) - .build() - .parse(token) - .getHeader() - .get("kid"); - return keyId; - } catch (Exception e) { - log.debug("Failed to extract key ID from token header: {}", e.getMessage()); + byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]); + Map header = + OBJECT_MAPPER.readValue( + headerBytes, new TypeReference>() {}); + Object keyId = header.get("kid"); + return keyId instanceof String ? (String) keyId : null; + } catch (IllegalArgumentException e) { + log.debug("Failed to decode Base64 JWT header: {}", e.getMessage()); + return null; + } catch (java.io.IOException e) { + log.debug("Failed to parse JWT header as JSON: {}", e.getMessage()); return null; } } + + private long getExpirationMillis() { + int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes(); + int expiryMinutes = + configuredMinutes > 0 + ? configuredMinutes + : JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES; + return expiryMinutes * JwtConstants.MILLIS_PER_MINUTE; + } + + private long getAllowedClockSkewSeconds() { + int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds(); + return configuredSeconds >= 0 ? configuredSeconds : JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java index 2107f2ffd..cded5b31f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java @@ -25,6 +25,16 @@ public interface JwtServiceInterface { */ String generateToken(String username, Map claims); + /** + * Generate a JWT token for a specific username with custom expiry + * + * @param username the username for which to generate the token + * @param claims additional claims to include in the token + * @param expiryMinutes custom token lifetime in minutes + * @return JWT token as a string + */ + String generateToken(String username, Map claims, int expiryMinutes); + /** * Validate a JWT token * @@ -41,6 +51,15 @@ public interface JwtServiceInterface { */ String extractUsername(String token); + /** + * Extract username from JWT token while allowing expired tokens. Signature and token structure + * must still be valid. + * + * @param token the JWT token + * @return username extracted from token + */ + String extractUsernameAllowExpired(String token); + /** * Extract all claims from JWT token * @@ -49,6 +68,15 @@ public interface JwtServiceInterface { */ Map extractClaims(String token); + /** + * Extract all claims from JWT token while allowing expired tokens. Signature and token + * structure must still be valid. + * + * @param token the JWT token + * @return map of claims + */ + Map extractClaimsAllowExpired(String token); + /** * Check if token is expired * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java index 48bcddac0..d0c9f879b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java @@ -10,8 +10,10 @@ import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; +import java.security.interfaces.RSAPrivateCrtKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPublicKeySpec; import java.security.spec.X509EncodedKeySpec; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -41,6 +43,7 @@ import stirling.software.proprietary.security.model.JwtVerificationKey; public class KeyPersistenceService implements KeyPersistenceServiceInterface { public static final String KEY_SUFFIX = ".key"; + public static final String PUB_KEY_SUFFIX = ".pub"; private final ApplicationProperties.Security.Jwt jwtProperties; private final CacheManager cacheManager; @@ -59,19 +62,119 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { @PostConstruct public void initializeKeystore() { if (!isKeystoreEnabled()) { + log.info("JWT keystore is disabled - keys will be generated in memory"); return; } try { ensurePrivateKeyDirectoryExists(); - loadKeyPair(); + loadExistingKeysFromDisk(); } catch (Exception e) { log.error("Failed to initialize keystore, using in-memory generation", e); } } - private void loadKeyPair() { - if (activeKey == null) { + /** + * Load all existing JWT keys from disk into memory on startup. + * + *

This ensures tokens signed with previous keys remain valid after server restart. If no + * keys exist on disk, generates a new keypair. + */ + private void loadExistingKeysFromDisk() { + try { + Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath()); + + if (!Files.exists(keyDirectory)) { + log.info("No existing keys found, generating new keypair"); + generateAndStoreKeypair(); + return; + } + + List keyFiles; + try (var stream = Files.list(keyDirectory)) { + keyFiles = + stream.filter(path -> path.toString().endsWith(KEY_SUFFIX)) + .sorted( + (a, b) -> + b.getFileName().compareTo(a.getFileName())) // Most + // recent + // first + .collect(Collectors.toList()); + } + + if (keyFiles.isEmpty()) { + log.info("No existing keys found in directory, generating new keypair"); + generateAndStoreKeypair(); + return; + } + + log.info("Loading {} existing JWT keys from disk", keyFiles.size()); + int loadedCount = 0; + + for (Path keyFile : keyFiles) { + try { + String keyId = keyFile.getFileName().toString().replace(KEY_SUFFIX, ""); + + // Load private key first + PrivateKey privateKey = loadPrivateKey(keyId); + + // Try to load public key, or generate it from private key if missing + // (migration) + String encodedPublicKey; + try { + encodedPublicKey = loadPublicKey(keyId); + } catch (IOException e) { + // Public key file doesn't exist - generate it from private key (migration) + log.info("Migrating legacy key: generating public key file for {}", keyId); + KeyPair keyPair = reconstructKeyPair(privateKey); + + // Save the public key file + Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX); + encodedPublicKey = encodePublicKey(keyPair.getPublic()); + Files.writeString(publicKeyFile, encodedPublicKey); + publicKeyFile.toFile().setReadable(true, true); + publicKeyFile.toFile().setWritable(true, true); + publicKeyFile.toFile().setExecutable(false, false); + + log.info("Successfully migrated key: {}", keyId); + } + + // Create verification key and add to cache + JwtVerificationKey verifyingKey = + new JwtVerificationKey(keyId, encodedPublicKey); + verifyingKeyCache.put(keyId, verifyingKey); + loadedCount++; + + // Set the most recent key as active (first in sorted list) + if (activeKey == null) { + activeKey = verifyingKey; + log.info("Set active JWT signing key: {}", keyId); + } else { + log.debug( + "Loaded historical JWT key: {} (created: {})", + keyId, + verifyingKey.getCreatedAt()); + } + } catch (Exception e) { + log.warn( + "Failed to load key: {}, skipping. Error: {}", + keyFile.getFileName(), + e.getMessage()); + } + } + + if (loadedCount == 0) { + log.warn("No valid keys could be loaded from disk, generating new keypair"); + generateAndStoreKeypair(); + } else { + log.info( + "Successfully loaded {} JWT keys, active key: {}", + loadedCount, + activeKey.getKeyId()); + } + + } catch (IOException e) { + log.error("Failed to load keys from disk, generating new keypair", e); generateAndStoreKeypair(); } } @@ -84,10 +187,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { KeyPair keyPair = generateRSAKeypair(); String keyId = generateKeyId(); - storePrivateKey(keyId, keyPair.getPrivate()); + storeKeyPair(keyId, keyPair); verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic())); verifyingKeyCache.put(keyId, verifyingKey); activeKey = verifyingKey; + log.info("Generated and stored new JWT keypair: {}", keyId); } catch (IOException e) { log.error("Failed to generate and store keypair", e); } @@ -200,16 +304,43 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { } } - private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException { - Path keyFile = - Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX); - String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded()); - Files.writeString(keyFile, encodedKey); + /** + * Store both private and public keys to disk. + * + *

Private key stored as: keyId.key + * + *

Public key stored as: keyId.pub + */ + private void storeKeyPair(String keyId, KeyPair keyPair) throws IOException { + Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath()); - // Set read/write to only the owner - keyFile.toFile().setReadable(true, true); - keyFile.toFile().setWritable(true, true); - keyFile.toFile().setExecutable(false, false); + // Store private key + Path privateKeyFile = keyDirectory.resolve(keyId + KEY_SUFFIX); + String encodedPrivateKey = + Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); + Files.writeString(privateKeyFile, encodedPrivateKey); + + // Set read/write to only the owner (security) + privateKeyFile.toFile().setReadable(true, true); + privateKeyFile.toFile().setWritable(true, true); + privateKeyFile.toFile().setExecutable(false, false); + + // Store public key + Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX); + String encodedPublicKey = + Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + Files.writeString(publicKeyFile, encodedPublicKey); + + // Public key can be more permissive but still restrict to owner + publicKeyFile.toFile().setReadable(true, true); + publicKeyFile.toFile().setWritable(true, true); + publicKeyFile.toFile().setExecutable(false, false); + + log.debug( + "Stored keypair to disk: {} (private: {}, public: {})", + keyId, + privateKeyFile.getFileName(), + publicKeyFile.getFileName()); } private PrivateKey loadPrivateKey(String keyId) @@ -229,6 +360,53 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { return keyFactory.generatePrivate(keySpec); } + /** + * Load public key from disk. + * + * @param keyId the key identifier + * @return Base64-encoded public key string + * @throws IOException if the public key file is not found + */ + private String loadPublicKey(String keyId) throws IOException { + Path publicKeyFile = + Paths.get(InstallationPathConfig.getPrivateKeyPath()) + .resolve(keyId + PUB_KEY_SUFFIX); + + if (!Files.exists(publicKeyFile)) { + throw new IOException("Public key not found: " + publicKeyFile); + } + + return Files.readString(publicKeyFile).trim(); + } + + /** + * Reconstruct a KeyPair from a PrivateKey. + * + *

For RSA keys, derives the public key from the private key. + * + * @param privateKey the RSA private key + * @return reconstructed KeyPair + * @throws NoSuchAlgorithmException if RSA algorithm is not available + * @throws InvalidKeySpecException if the key specification is invalid + */ + private KeyPair reconstructKeyPair(PrivateKey privateKey) + throws NoSuchAlgorithmException, InvalidKeySpecException { + // For RSA, we can derive the public key from the private key + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + // Get the private key spec + RSAPrivateCrtKey rsaPrivateKey = (RSAPrivateCrtKey) privateKey; + + // Create public key spec from private key parameters + RSAPublicKeySpec publicKeySpec = + new RSAPublicKeySpec(rsaPrivateKey.getModulus(), rsaPrivateKey.getPublicExponent()); + + // Generate public key + PublicKey publicKey = keyFactory.generatePublic(publicKeySpec); + + return new KeyPair(publicKey, privateKey); + } + private String encodePublicKey(PublicKey publicKey) { return Base64.getEncoder().encodeToString(publicKey.getEncoded()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java new file mode 100644 index 000000000..bb4e45429 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java @@ -0,0 +1,124 @@ +package stirling.software.proprietary.security.service; + +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.constants.JwtConstants; +import stirling.software.common.model.ApplicationProperties; + +/** + * Service to rate limit token refresh attempts within the grace period. + * + *

Prevents abuse of expired tokens by tracking and limiting refresh attempts per token. Tokens + * are identified by a hash to avoid storing actual token values. + */ +@Service +@Slf4j +public class RefreshRateLimitService { + + private final ApplicationProperties.Security.Jwt jwtProperties; + + @Autowired + public RefreshRateLimitService(ApplicationProperties applicationProperties) { + this.jwtProperties = applicationProperties.getSecurity().getJwt(); + } + + private static class RefreshAttempt { + private final AtomicInteger count = new AtomicInteger(0); + private final Instant firstAttempt = Instant.now(); + + int incrementAndGet() { + return count.incrementAndGet(); + } + + Instant getFirstAttempt() { + return firstAttempt; + } + + int getCount() { + return count.get(); + } + } + + private final Map attempts = new ConcurrentHashMap<>(); + + /** + * Check if a refresh attempt is allowed for the given token. + * + * @param tokenHash hash of the token attempting refresh + * @param graceWindowMillis the configured grace window in milliseconds + * @return true if refresh is allowed, false if rate limit exceeded + */ + public boolean isRefreshAllowed(String tokenHash, long graceWindowMillis) { + RefreshAttempt attempt = attempts.computeIfAbsent(tokenHash, k -> new RefreshAttempt()); + + int attemptCount = attempt.incrementAndGet(); + + if (attemptCount > JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE) { + log.warn( + "Refresh rate limit exceeded for token (attempt {}). Token hash: {}", + attemptCount, + tokenHash.substring(0, Math.min(8, tokenHash.length()))); + return false; + } + + // Clean up if outside grace window + Instant cutoff = Instant.now().minusMillis(graceWindowMillis); + if (attempt.getFirstAttempt().isBefore(cutoff)) { + attempts.remove(tokenHash); + } + + return true; + } + + /** + * Remove tracking for a token after successful refresh. + * + * @param tokenHash hash of the refreshed token + */ + public void clearRefreshAttempts(String tokenHash) { + attempts.remove(tokenHash); + } + + /** Clean up expired tracking entries every 5 minutes. */ + @Scheduled(fixedRate = 300000) + public void cleanupExpiredEntries() { + // Use configured grace period with same normalization as runtime checks + int configuredMinutes = jwtProperties.getRefreshGraceMinutes(); + int graceMinutes = + configuredMinutes >= 0 + ? configuredMinutes + : JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES; + Instant cutoff = Instant.now().minusMillis(graceMinutes * 60000L); + int removed = + attempts.entrySet().stream() + .filter(entry -> entry.getValue().getFirstAttempt().isBefore(cutoff)) + .mapToInt( + entry -> { + attempts.remove(entry.getKey()); + return 1; + }) + .sum(); + + if (removed > 0) { + log.debug("Cleaned up {} expired refresh tracking entries", removed); + } + } + + /** Get current tracking statistics for monitoring. */ + public Map getStats() { + return Map.of( + "tracked_tokens", + attempts.size(), + "max_attempts_allowed", + JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/util/DesktopClientUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/util/DesktopClientUtils.java new file mode 100644 index 000000000..3ccfd8e77 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/util/DesktopClientUtils.java @@ -0,0 +1,82 @@ +package stirling.software.proprietary.security.util; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.constants.JwtConstants; +import stirling.software.common.model.ApplicationProperties; + +/** + * Utility class for detecting desktop clients and determining appropriate token expiry times. + * + *

Desktop clients (Tauri, Electron) receive longer-lived tokens because: + * + *

    + *
  • They run on personal devices (not shared computers) + *
  • Tokens stored in OS-level encrypted keychain (not browser localStorage) + *
  • Better UX (users expect desktop apps to stay logged in) + *
+ */ +@Slf4j +public class DesktopClientUtils { + + private DesktopClientUtils() { + // Utility class - prevent instantiation + } + + /** + * Detect if the request is from a desktop client (Tauri app). + * + * @param request the HTTP request + * @return true if desktop client, false if web browser + */ + public static boolean isDesktopClient(HttpServletRequest request) { + String userAgent = request.getHeader("User-Agent"); + + if (userAgent == null) { + return false; + } + + // Tauri desktop app includes "Tauri" or "tauri-plugin" in User-Agent + // Also check for common desktop app identifiers + String userAgentLower = userAgent.toLowerCase(); + boolean hasTauri = userAgentLower.contains("tauri"); + boolean hasStirling = userAgentLower.contains("stirlingpdf-desktop"); + boolean hasElectron = userAgentLower.contains("electron"); + boolean isDesktop = hasTauri || hasStirling || hasElectron; + + log.debug("Desktop client detection: {} (User-Agent: {})", isDesktop, userAgent); + + return isDesktop; + } + + /** + * Get the configured desktop token expiry time in minutes. + * + * @param applicationProperties the application properties + * @return desktop token expiry in minutes (defaults to 30 days if not configured) + */ + public static int getDesktopTokenExpiryMinutes(ApplicationProperties applicationProperties) { + int configuredMinutes = + applicationProperties.getSecurity().getJwt().getDesktopTokenExpiryMinutes(); + // If not configured or invalid, default to 30 days (43200 minutes) + return configuredMinutes > 0 + ? configuredMinutes + : JwtConstants.DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES; + } + + /** + * Get the configured web token expiry time in minutes. + * + * @param applicationProperties the application properties + * @return web token expiry in minutes + */ + public static int getWebTokenExpiryMinutes(ApplicationProperties applicationProperties) { + int configuredMinutes = + applicationProperties.getSecurity().getJwt().getTokenExpiryMinutes(); + return configuredMinutes > 0 + ? configuredMinutes + : JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java index 86bcf50e8..fd1a99e7b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java @@ -10,6 +10,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.Date; +import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -36,6 +38,7 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.MfaService; +import stirling.software.proprietary.security.service.RefreshRateLimitService; import stirling.software.proprietary.security.service.TotpService; import stirling.software.proprietary.security.service.UserService; @@ -53,11 +56,17 @@ class AuthControllerLoginTest { @Mock private LoginAttemptService loginAttemptService; @Mock private MfaService mfaService; @Mock private TotpService totpService; + @Mock private RefreshRateLimitService refreshRateLimitService; @BeforeEach void setUp() { securityProperties = new ApplicationProperties.Security(); securityProperties.setLoginMethod("all"); + securityProperties.getJwt().setTokenExpiryMinutes(60); + securityProperties.getJwt().setRefreshGraceMinutes(5); + + ApplicationProperties applicationProperties = new ApplicationProperties(); + applicationProperties.setSecurity(securityProperties); AuthController controller = new AuthController( @@ -67,7 +76,9 @@ class AuthControllerLoginTest { loginAttemptService, mfaService, totpService, - securityProperties); + refreshRateLimitService, + securityProperties, + applicationProperties); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } @@ -175,7 +186,11 @@ class AuthControllerLoginTest { void refreshReturnsNewTokenWhenValid() throws Exception { User user = buildUser(); when(jwtService.extractToken(any())).thenReturn("old"); - when(jwtService.extractUsername("old")).thenReturn("user@example.com"); + Map claims = new HashMap<>(); + claims.put("sub", "user@example.com"); + claims.put("exp", new Date(System.currentTimeMillis() + 60_000)); + when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims); + // Rate limiting is not checked for valid tokens, so no stub needed when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user); when(jwtService.generateToken(eq("user@example.com"), any(Map.class))) .thenReturn("new-token"); @@ -184,7 +199,75 @@ class AuthControllerLoginTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.user").exists()) .andExpect(jsonPath("$.session.access_token").value("new-token")) - .andExpect(jsonPath("$.session.expires_in").value(3600)); + .andExpect( + jsonPath("$.session.expires_in") + .value(3600)); // 60 minutes * 60 = 3600 seconds + + // clearRefreshAttempts is intentionally not called - tokens expire naturally after grace + // period + } + + @Test + void refreshRejectsTokenExpiredBeyondGrace() throws Exception { + when(jwtService.extractToken(any())).thenReturn("old"); + Map claims = new HashMap<>(); + claims.put("sub", "user@example.com"); + claims.put( + "exp", + new Date( + System.currentTimeMillis() + - (10 * 60_000))); // 10 minutes ago, beyond 5 minute grace + when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims); + + mockMvc.perform(post("/api/v1/auth/refresh")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("Token refresh failed")); + + verify(userDetailsService, never()).loadUserByUsername(any()); + verify(refreshRateLimitService, never()).isRefreshAllowed(any(), any(Long.class)); + } + + @Test + void refreshAcceptsTokenExpiredWithinGrace() throws Exception { + User user = buildUser(); + when(jwtService.extractToken(any())).thenReturn("old"); + Map claims = new HashMap<>(); + claims.put("sub", "user@example.com"); + claims.put( + "exp", + new Date( + System.currentTimeMillis() + - 60_000)); // 1 minute ago, within 5 minute grace + when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims); + when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(true); + when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user); + when(jwtService.generateToken(eq("user@example.com"), any(Map.class))) + .thenReturn("new-token"); + + mockMvc.perform(post("/api/v1/auth/refresh")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.session.access_token").value("new-token")); + + // clearRefreshAttempts is intentionally not called - tokens expire naturally after grace + // period + } + + @Test + void refreshRejectsWhenRateLimitExceeded() throws Exception { + when(jwtService.extractToken(any())).thenReturn("old"); + Map claims = new HashMap<>(); + claims.put("sub", "user@example.com"); + claims.put("exp", new Date(System.currentTimeMillis() - 60_000)); // 1 minute ago + when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims); + when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(false); + + mockMvc.perform(post("/api/v1/auth/refresh")) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.error").value("Too many refresh attempts")) + .andExpect(jsonPath("$.max_attempts").exists()); + + verify(userDetailsService, never()).loadUserByUsername(any()); + verify(refreshRateLimitService, never()).clearRefreshAttempts(any()); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java index 376d0b4ed..b61883949 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java @@ -37,13 +37,19 @@ class CustomOAuth2AuthenticationSuccessHandlerTest { oauth2Props.setAutoCreateUser(true); oauth2Props.setBlockRegistration(false); + ApplicationProperties applicationProperties = new ApplicationProperties(); + ApplicationProperties.Security securityProperties = new ApplicationProperties.Security(); + securityProperties.setOauth2(oauth2Props); + applicationProperties.setSecurity(securityProperties); + CustomOAuth2AuthenticationSuccessHandler handler = new CustomOAuth2AuthenticationSuccessHandler( loginAttemptService, oauth2Props, userService, jwtService, - licenseSettingsService); + licenseSettingsService, + applicationProperties); when(userService.usernameExistsIgnoreCase("user")).thenReturn(false); when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java index e8a6d6045..787aa57e3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -31,6 +31,7 @@ import org.springframework.security.core.Authentication; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.model.JwtVerificationKey; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; @@ -64,7 +65,8 @@ class JwtServiceTest { Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey); - jwtService = new JwtService(true, keystoreService); + ApplicationProperties applicationProperties = new ApplicationProperties(); + jwtService = new JwtService(true, keystoreService, applicationProperties); } @Test @@ -73,8 +75,6 @@ class JwtServiceTest { when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); - when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) - .thenReturn(testKeyPair.getPublic()); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -94,8 +94,6 @@ class JwtServiceTest { when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); - when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) - .thenReturn(testKeyPair.getPublic()); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -114,8 +112,6 @@ class JwtServiceTest { void testValidateTokenSuccess() throws Exception { when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); - when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) - .thenReturn(testKeyPair.getPublic()); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn("testuser"); @@ -179,8 +175,6 @@ class JwtServiceTest { when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); - when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) - .thenReturn(testKeyPair.getPublic()); when(authentication.getPrincipal()).thenReturn(user); when(user.getUsername()).thenReturn(username); @@ -207,8 +201,6 @@ class JwtServiceTest { when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); - when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) - .thenReturn(testKeyPair.getPublic()); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -281,8 +273,6 @@ class JwtServiceTest { when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); - when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) - .thenReturn(testKeyPair.getPublic()); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -307,8 +297,6 @@ class JwtServiceTest { // First, generate a token successfully when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); - when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) - .thenReturn(testKeyPair.getPublic()); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); diff --git a/build.gradle b/build.gradle index fdf864b86..fd706cb6c 100644 --- a/build.gradle +++ b/build.gradle @@ -67,7 +67,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.4.5' + version = '2.5.0' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 33dee7950..16660e79d 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 0da39ba5a..7c5b3d957 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -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", diff --git a/frontend/src/core/testing/serverExperienceSimulations.ts b/frontend/src/core/testing/serverExperienceSimulations.ts index 7bafd2000..9a922911b 100644 --- a/frontend/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/src/core/testing/serverExperienceSimulations.ts @@ -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, diff --git a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx index bf53cd05b..d709315a3 100644 --- a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx +++ b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx @@ -227,6 +227,10 @@ export const ServerSelection: React.FC = ({ 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 })} diff --git a/frontend/src/desktop/extensions/authSessionCleanup.ts b/frontend/src/desktop/extensions/authSessionCleanup.ts index a4d8eb6ea..916fceec0 100644 --- a/frontend/src/desktop/extensions/authSessionCleanup.ts +++ b/frontend/src/desktop/extensions/authSessionCleanup.ts @@ -13,7 +13,17 @@ export async function clearPlatformAuthAfterSignOut(): Promise { export async function clearPlatformAuthOnLoginInit(): Promise { 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); } diff --git a/frontend/src/desktop/extensions/platformSessionBridge.ts b/frontend/src/desktop/extensions/platformSessionBridge.ts new file mode 100644 index 000000000..2b9a5f1f7 --- /dev/null +++ b/frontend/src/desktop/extensions/platformSessionBridge.ts @@ -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 { + 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 { + 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 { + 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 { + try { + await authService.saveToken(token); + } catch (error) { + console.error('[PlatformBridge] Failed to save token:', error); + throw error; + } +} diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts index eae32c383..0a5037b1f 100644 --- a/frontend/src/desktop/services/apiClientSetup.ts +++ b/frontend/src/desktop/services/apiClientSetup.ts @@ -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); } diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index b3f3da911..3095b6cb2 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -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 | 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 { + private async saveTokenEverywhere( + token: string, + refreshToken?: string | null, + emitJwtAvailable = true + ): Promise { // 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('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 { - const token = await invoke('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('get_refresh_token'); } /** @@ -147,19 +131,16 @@ export class AuthService { private async clearTokenEverywhere(): Promise { // 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 { - 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('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 { + await this.saveTokenEverywhere(token, undefined, false); + } + async logout(): Promise { 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 { 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 { + 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 { // 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(); diff --git a/frontend/src/desktop/services/tauriBackendService.ts b/frontend/src/desktop/services/tauriBackendService.ts index 7cd5c0942..ceb39c4d8 100644 --- a/frontend/src/desktop/services/tauriBackendService.ts +++ b/frontend/src/desktop/services/tauriBackendService.ts @@ -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 { - // Check localStorage first (web layer token) - const localStorageToken = localStorage.getItem('stirling_jwt'); - if (localStorageToken) { - return localStorageToken; - } - - // Fallback to Tauri store try { - return await invoke('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; } } diff --git a/frontend/src/proprietary/auth/springAuthClient.test.ts b/frontend/src/proprietary/auth/springAuthClient.test.ts index 0a874cd4e..cae070373 100644 --- a/frontend/src/proprietary/auth/springAuthClient.test.ts +++ b/frontend/src/proprietary/auth/springAuthClient.test.ts @@ -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(); }); diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 7f7039218..373df79c7 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -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 | 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() { diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx index e5c1f41ca..2bfc8f1b4 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx @@ -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() { +
- {t('admin.settings.security.jwt.keyRetentionDays.label', 'Key Retention Days')} - + {t('admin.settings.security.jwt.tokenExpiryMinutes.label', 'Web Token Expiry (minutes)')} + } - 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} + /> +
+ +
+ + {t('admin.settings.security.jwt.desktopTokenExpiryMinutes.label', 'Desktop Token Expiry (minutes)')} + + + } + 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} + /> +
+ +
+ + {t('admin.settings.security.jwt.allowedClockSkewSeconds.label', 'Clock Skew Tolerance (seconds)')} + + + } + 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} + /> +
+ +
+ + {t('admin.settings.security.jwt.refreshGraceMinutes.label', 'Refresh Grace Period (minutes)')} + + + } + 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} />
diff --git a/frontend/src/proprietary/extensions/platformSessionBridge.ts b/frontend/src/proprietary/extensions/platformSessionBridge.ts new file mode 100644 index 000000000..cba00d886 --- /dev/null +++ b/frontend/src/proprietary/extensions/platformSessionBridge.ts @@ -0,0 +1,33 @@ +export interface PlatformSessionUser { + username: string; + email?: string; +} + +/** + * Proprietary/web default: no desktop SaaS auth bridge. + */ +export async function isDesktopSaaSAuthMode(): Promise { + return false; +} + +/** + * Proprietary/web default: no platform user store. + */ +export async function getPlatformSessionUser(): Promise { + return null; +} + +/** + * Proprietary/web default: no platform refresh path. + */ +export async function refreshPlatformSession(): Promise { + return false; +} + +/** + * Proprietary/web default: no platform-specific token storage (uses localStorage only). + */ +export async function savePlatformToken(_token: string): Promise { + // Web mode: token already saved to localStorage in springAuthClient + // No additional platform storage needed +} diff --git a/frontend/src/proprietary/services/apiClientSetup.ts b/frontend/src/proprietary/services/apiClientSetup.ts index 9b1ed75bd..91c354999 100644 --- a/frontend/src/proprietary/services/apiClientSetup.ts +++ b/frontend/src/proprietary/services/apiClientSetup.ts @@ -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 { + 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); + } + ); } diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts index dad9866f3..e866976d5 100644 --- a/frontend/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts @@ -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, diff --git a/testing/allEndpointsRemovedSettings.yml b/testing/allEndpointsRemovedSettings.yml index 4e5503c41..4c23125bc 100644 --- a/testing/allEndpointsRemovedSettings.yml +++ b/testing/allEndpointsRemovedSettings.yml @@ -62,7 +62,6 @@ security: persistence: true # Set to 'true' to enable JWT key store enableKeyRotation: true # Set to 'true' to enable key pair rotation enableKeyCleanup: true # Set to 'true' to enable key pair cleanup - keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days. validation: # PDF signature validation settings trust: serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)