From da2eb54fe8a8ca82b472e15a2c442a7040715f2f Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:49:23 +0000 Subject: [PATCH 1/3] fix_env_files_for_tauri (#5741) https://vite.dev/config/#using-environment-variables-in-config --- frontend/vite.config.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1e39ddf9eb..2560e6f5da 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,15 +1,21 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { viteStaticCopy } from 'vite-plugin-static-copy'; export default defineConfig(({ mode }) => { + + // Load env file based on `mode` in the current working directory. + // Set the third parameter to '' to load all env regardless of the + // `VITE_` prefix. + const env = loadEnv(mode, process.cwd(), '') + // When DISABLE_ADDITIONAL_FEATURES is false (or unset), enable proprietary features const isProprietary = process.env.DISABLE_ADDITIONAL_FEATURES !== 'true'; const isDesktopMode = mode === 'desktop' || - process.env.STIRLING_DESKTOP === 'true' || - process.env.VITE_DESKTOP === 'true'; + env.STIRLING_DESKTOP === 'true' || + env.VITE_DESKTOP === 'true'; // Validate required environment variables for desktop builds if (isDesktopMode) { @@ -18,7 +24,7 @@ export default defineConfig(({ mode }) => { 'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY', ]; - const missingVars = requiredEnvVars.filter(varName => !process.env[varName]); + const missingVars = requiredEnvVars.filter(varName => !env[varName]); if (missingVars.length > 0) { throw new Error( @@ -108,6 +114,6 @@ export default defineConfig(({ mode }) => { }, }, }, - base: process.env.RUN_SUBPATH ? `/${process.env.RUN_SUBPATH}` : './', + base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : './', }; }); 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 2/3] 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 0000000000..a05df9bde4 --- /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 c6988098cf..d0c3691958 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 7bd56e700a..be51dd74eb 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 a15da437d5..53c252e2db 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 ba074a5da1..501826a084 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 06efcf3a1d..f3a8f25b58 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 c7ebdbdb4f..62b5f55477 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 08332dd4b9..e86857d33d 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 8076829ec0..f790cbac36 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 60472fef42..a551ab9070 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 2107f2ffd6..cded5b31fc 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 48bcddac0d..d0c9f879be 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 0000000000..bb4e454290 --- /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 0000000000..3ccfd8e773 --- /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 86bcf50e8c..fd1a99e7b1 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 376d0b4ed7..b61883949e 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 e8a6d60453..787aa57e3f 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 fdf864b862..fd706cb6ca 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 33dee79505..16660e79d4 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 0da39ba5a6..7c5b3d957f 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 7bafd20005..9a922911b8 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 bf53cd05bf..d709315a31 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 a4d8eb6eab..916fceec01 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 0000000000..2b9a5f1f76 --- /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 eae32c3833..0a5037b1f3 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 b3f3da9114..3095b6cb22 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 7cd5c09426..ceb39c4d8f 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 0a874cd4e6..cae070373a 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 7f70392184..373df79c76 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 e5c1f41caf..2bfc8f1b44 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 0000000000..cba00d8862 --- /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 9b1ed75bd6..91c354999c 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 dad9866f3c..e866976d59 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 4e5503c415..4c23125bca 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) From 757a666f5ee700dea363a73b118389e8fa23b1ac Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:01:15 +0000 Subject: [PATCH 3/3] Chore/v2/improve annotation UI (#5724) --- .../public/locales/en-GB/translation.toml | 105 +-- .../annotation/shared/ColorControl.tsx | 59 ++ .../annotation/shared/OpacityControl.tsx | 60 ++ .../annotation/shared/PropertiesPopover.tsx | 211 ++++++ .../annotation/shared/WidthControl.tsx | 62 ++ .../viewer/AnnotationSelectionMenu.tsx | 620 ++++++++++++++++++ .../core/components/viewer/LocalEmbedPDF.tsx | 3 +- frontend/src/core/tools/Annotate.tsx | 1 - .../core/tools/annotate/AnnotationPanel.tsx | 521 +-------------- .../tools/annotate/useAnnotationSelection.ts | 30 +- 10 files changed, 1088 insertions(+), 584 deletions(-) create mode 100644 frontend/src/core/components/annotation/shared/ColorControl.tsx create mode 100644 frontend/src/core/components/annotation/shared/OpacityControl.tsx create mode 100644 frontend/src/core/components/annotation/shared/PropertiesPopover.tsx create mode 100644 frontend/src/core/components/annotation/shared/WidthControl.tsx create mode 100644 frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 16660e79d4..62e629f19f 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -1439,13 +1439,16 @@ applyChanges = "Apply Changes" backgroundColor = "Background colour" borderOff = "Border: Off" borderOn = "Border: On" +changeColor = "Change Colour" chooseColor = "Choose colour" circle = "Circle" clearBackground = "Remove background" color = "Colour" contents = "Text" +delete = "Delete" desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required." drawing = "Drawing" +duplicate = "Duplicate" editCircle = "Edit Circle" editInk = "Edit Pen" editLine = "Edit Line" @@ -1475,6 +1478,7 @@ notesStamps = "Notes & Stamps" opacity = "Opacity" pen = "Pen" polygon = "Polygon" +properties = "Properties" rectangle = "Rectangle" redo = "Redo" saveChanges = "Save Changes" @@ -1500,6 +1504,7 @@ title = "Annotate" underline = "Underline" undo = "Undo" unsupportedType = "This annotation type is not fully supported for editing." +width = "Width" [app] description = "The Free Adobe Acrobat alternative (10M+ Downloads)" @@ -4230,6 +4235,56 @@ title = "Page Editor" zoomIn = "Zoom In" zoomOut = "Zoom Out" +[viewer] +cannotPreviewFile = "Cannot Preview File" +dualPageView = "Dual Page View" +firstPage = "First Page" +lastPage = "Last Page" +nextPage = "Next Page" +onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format." +previousPage = "Previous Page" +singlePageView = "Single Page View" +unknownFile = "Unknown file" +zoomIn = "Zoom In" +zoomOut = "Zoom Out" + +[rightRail] +closeSelected = "Close Selected Files" +selectAll = "Select All" +deselectAll = "Deselect All" +selectByNumber = "Select by Page Numbers" +deleteSelected = "Delete Selected Pages" +closePdf = "Close PDF" +exportAll = "Export PDF" +downloadSelected = "Download Selected Files" +annotations = "Annotations" +exportSelected = "Export Selected Pages" +formFill = "Fill Form" +saveChanges = "Save Changes" +toggleAttachments = "Toggle Attachments" +toggleTheme = "Toggle Theme" +language = "Language" +toggleAnnotations = "Toggle Annotations Visibility" +search = "Search PDF" +panMode = "Pan Mode" +applyRedactionsFirst = "Apply redactions first" +rotateLeft = "Rotate Left" +rotateRight = "Rotate Right" +toggleSidebar = "Toggle Sidebar" +toggleBookmarks = "Toggle Bookmarks" +print = "Print PDF" +draw = "Draw" +redact = "Redact" +exitRedaction = "Exit Redaction Mode" +save = "Save" +downloadAll = "Download All" +saveAll = "Save All" + +[textAlign] +left = "Left" +center = "Center" +right = "Right" + [pageExtracter] header = "Extract Pages" placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" @@ -5310,38 +5365,6 @@ title = "High Contrast" text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions." title = "Invert All Colours" -[rightRail] -annotations = "Annotations" -applyRedactionsFirst = "Apply redactions first" -closePdf = "Close PDF" -closeSelected = "Close Selected Files" -formFill = "Fill Form" -deleteSelected = "Delete Selected Pages" -deselectAll = "Deselect All" -downloadAll = "Download All" -downloadSelected = "Download Selected Files" -draw = "Draw" -exitRedaction = "Exit Redaction Mode" -exportAll = "Export PDF" -exportSelected = "Export Selected Pages" -language = "Language" -panMode = "Pan Mode" -print = "Print PDF" -redact = "Redact" -rotateLeft = "Rotate Left" -rotateRight = "Rotate Right" -save = "Save" -saveAll = "Save All" -saveChanges = "Save Changes" -search = "Search PDF" -selectAll = "Select All" -selectByNumber = "Select by Page Numbers" -toggleAnnotations = "Toggle Annotations Visibility" -toggleBookmarks = "Toggle Bookmarks" -toggleAttachments = "Toggle Attachments" -toggleSidebar = "Toggle Sidebar" -toggleTheme = "Toggle Theme" - [rotate] rotateLeft = "Rotate Anticlockwise" rotateRight = "Rotate Clockwise" @@ -6179,11 +6202,6 @@ title = "API Documentation" [tableExtraxt] tags = "CSV,Table Extraction,extract,convert" -[textAlign] -center = "Center" -left = "Left" -right = "Right" - [theme] toggle = "Toggle Theme" @@ -6459,19 +6477,6 @@ fileManager = "File Manager" pageEditor = "Page Editor" viewer = "Viewer" -[viewer] -cannotPreviewFile = "Cannot Preview File" -dualPageView = "Dual Page View" -firstPage = "First Page" -lastPage = "Last Page" -nextPage = "Next Page" -onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format." -previousPage = "Previous Page" -singlePageView = "Single Page View" -unknownFile = "Unknown file" -zoomIn = "Zoom In" -zoomOut = "Zoom Out" - [viewer.attachments] title = "Attachments" searchPlaceholder = "Search attachments" diff --git a/frontend/src/core/components/annotation/shared/ColorControl.tsx b/frontend/src/core/components/annotation/shared/ColorControl.tsx new file mode 100644 index 0000000000..51153c071f --- /dev/null +++ b/frontend/src/core/components/annotation/shared/ColorControl.tsx @@ -0,0 +1,59 @@ +import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker } from '@mantine/core'; +import { useState } from 'react'; + +interface ColorControlProps { + value: string; + onChange: (color: string) => void; + label: string; + disabled?: boolean; +} + +export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) { + const [opened, setOpened] = useState(false); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + + + + + + ); +} diff --git a/frontend/src/core/components/annotation/shared/OpacityControl.tsx b/frontend/src/core/components/annotation/shared/OpacityControl.tsx new file mode 100644 index 0000000000..27b1f10dd9 --- /dev/null +++ b/frontend/src/core/components/annotation/shared/OpacityControl.tsx @@ -0,0 +1,60 @@ +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import OpacityIcon from '@mui/icons-material/Opacity'; + +interface OpacityControlProps { + value: number; // 0-100 + onChange: (value: number) => void; + disabled?: boolean; +} + +export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + + + {t('annotation.opacity', 'Opacity')} + + `${val}%`} + /> + + + + ); +} diff --git a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx new file mode 100644 index 0000000000..bde9609b5f --- /dev/null +++ b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx @@ -0,0 +1,211 @@ +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import TuneIcon from '@mui/icons-material/Tune'; +import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft'; +import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter'; +import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight'; + +type AnnotationType = 'text' | 'note' | 'shape'; + +interface PropertiesPopoverProps { + annotationType: AnnotationType; + annotation: any; + onUpdate: (patch: Record) => void; + disabled?: boolean; +} + +export function PropertiesPopover({ + annotationType, + annotation, + onUpdate, + disabled = false, +}: PropertiesPopoverProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + + const obj = annotation?.object; + + // Get current values + const fontSize = obj?.fontSize ?? 14; + const textAlign = obj?.textAlign; + const currentAlign = + typeof textAlign === 'number' + ? textAlign === 1 + ? 'center' + : textAlign === 2 + ? 'right' + : 'left' + : textAlign === 'center' + ? 'center' + : textAlign === 'right' + ? 'right' + : 'left'; + + // For shapes + const opacity = Math.round((obj?.opacity ?? 1) * 100); + const strokeWidth = obj?.borderWidth ?? obj?.strokeWidth ?? 2; + const borderVisible = strokeWidth > 0; + + const renderTextNoteControls = () => ( + + {/* Font Size */} +
+ + {t('annotation.fontSize', 'Font size')} + + onUpdate({ fontSize: val })} + min={8} + max={32} + label={(val) => `${val}pt`} + /> +
+ + {/* Opacity */} +
+ + {t('annotation.opacity', 'Opacity')} + + onUpdate({ opacity: val / 100 })} + min={10} + max={100} + label={(val) => `${val}%`} + /> +
+ + {/* Text Alignment */} +
+ + {t('annotation.textAlignment', 'Text Alignment')} + + + onUpdate({ textAlign: 0 })} + size="md" + > + + + onUpdate({ textAlign: 1 })} + size="md" + > + + + onUpdate({ textAlign: 2 })} + size="md" + > + + + +
+
+ ); + + const renderShapeControls = () => ( + + {/* Opacity */} +
+ + {t('annotation.opacity', 'Opacity')} + + { + const newOpacity = val / 100; + onUpdate({ + opacity: newOpacity, + strokeOpacity: newOpacity, + fillOpacity: newOpacity, + }); + }} + min={10} + max={100} + label={(val) => `${val}%`} + /> +
+ + {/* Stroke Width */} +
+ +
+ + {t('annotation.strokeWidth', 'Stroke')} + + { + onUpdate({ + borderWidth: val, + strokeWidth: val, + lineWidth: val, + }); + }} + min={0} + max={12} + label={(val) => `${val}pt`} + /> +
+ +
+
+
+ ); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + {(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()} + {annotationType === 'shape' && renderShapeControls()} + + + ); +} diff --git a/frontend/src/core/components/annotation/shared/WidthControl.tsx b/frontend/src/core/components/annotation/shared/WidthControl.tsx new file mode 100644 index 0000000000..b99d35c996 --- /dev/null +++ b/frontend/src/core/components/annotation/shared/WidthControl.tsx @@ -0,0 +1,62 @@ +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import LineWeightIcon from '@mui/icons-material/LineWeight'; + +interface WidthControlProps { + value: number; + onChange: (value: number) => void; + min: number; // 1 for ink, 0 for shapes + max: number; // 12 for ink, 20 for highlighter + disabled?: boolean; +} + +export function WidthControl({ value, onChange, min, max, disabled = false }: WidthControlProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + + + {t('annotation.width', 'Width')} + + `${val}pt`} + /> + + + + ); +} diff --git a/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx new file mode 100644 index 0000000000..088e315630 --- /dev/null +++ b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx @@ -0,0 +1,620 @@ +import { ActionIcon, Tooltip, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { createPortal } from 'react-dom'; +import { useEffect, useState, useRef, useCallback } from 'react'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import { useAnnotation } from '@embedpdf/plugin-annotation/react'; +import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId'; +import { OpacityControl } from '@app/components/annotation/shared/OpacityControl'; +import { WidthControl } from '@app/components/annotation/shared/WidthControl'; +import { PropertiesPopover } from '@app/components/annotation/shared/PropertiesPopover'; +import { ColorControl } from '@app/components/annotation/shared/ColorControl'; + +/** + * Props interface matching EmbedPDF's annotation selection menu pattern + * This matches the type from @embedpdf/plugin-annotation + */ +export interface AnnotationSelectionMenuProps { + documentId?: string; + context?: { + type: 'annotation'; + annotation: any; + pageIndex: number; + }; + selected: boolean; + menuWrapperProps?: { + ref?: (node: HTMLDivElement | null) => void; + style?: React.CSSProperties; + }; +} + +export function AnnotationSelectionMenu(props: AnnotationSelectionMenuProps) { + const activeDocumentId = useActiveDocumentId(); + + // Don't render until we have a valid document ID + if (!activeDocumentId) { + return null; + } + + return ( + + ); +} + +type AnnotationType = 'textMarkup' | 'ink' | 'inkHighlighter' | 'text' | 'note' | 'shape' | 'line' | 'stamp' | 'unknown'; + +function AnnotationSelectionMenuInner({ + documentId, + context, + selected, + menuWrapperProps, +}: AnnotationSelectionMenuProps & { documentId: string }) { + const annotation = context?.annotation; + const pageIndex = context?.pageIndex; + const { t } = useTranslation(); + const { provides } = useAnnotation(documentId); + const wrapperRef = useRef(null); + const textareaRef = useRef(null); + const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); + const [isTextEditorOpen, setIsTextEditorOpen] = useState(false); + const [textDraft, setTextDraft] = useState(''); + const [textBoxPosition, setTextBoxPosition] = useState<{ top: number; left: number; width: number; height: number; fontSize: number; fontFamily: string } | null>(null); + + // Merge refs - menuWrapperProps.ref is a callback ref + const setRef = useCallback((node: HTMLDivElement | null) => { + wrapperRef.current = node; + // Call the EmbedPDF ref callback + menuWrapperProps?.ref?.(node); + }, [menuWrapperProps]); + + // Type detection + const getAnnotationType = useCallback((): AnnotationType => { + const type = annotation?.object?.type; + const toolId = annotation?.object?.customData?.toolId; + + // Map type numbers to categories + if ([9, 10, 11, 12].includes(type)) return 'textMarkup'; + if (type === 15) { + return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink'; + } + if (type === 3) { + return toolId === 'note' ? 'note' : 'text'; + } + if ([5, 6, 7].includes(type)) return 'shape'; + if ([4, 8].includes(type)) return 'line'; + if (type === 13) return 'stamp'; + + return 'unknown'; + }, [annotation]); + + // Calculate menu width based on annotation type + const calculateWidth = (annotationType: AnnotationType): number => { + switch (annotationType) { + case 'stamp': + return 80; + case 'inkHighlighter': + return 220; + case 'shape': + return 200; + default: + return 180; + } + }; + + // Get annotation properties + const obj = annotation?.object; + const annotationType = getAnnotationType(); + const annotationId = obj?.id; + + // Get current colors + const getCurrentColor = (): string => { + if (!obj) return '#000000'; + const type = obj.type; + // Text annotations use textColor + if (type === 3) return obj.textColor || obj.color || '#000000'; + // Shape annotations use strokeColor + if ([4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000'; + // Default to color property + return obj.color || obj.strokeColor || '#000000'; + }; + + const getStrokeColor = (): string => { + return obj?.strokeColor || obj?.color || '#000000'; + }; + + const getFillColor = (): string => { + return obj?.color || obj?.fillColor || '#0000ff'; + }; + + const getBackgroundColor = (): string => { + // Check multiple possible properties for background color + return obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff'; + }; + + const getTextColor = (): string => { + return obj?.textColor || obj?.color || '#000000'; + }; + + const getOpacity = (): number => { + return Math.round((obj?.opacity ?? 1) * 100); + }; + + const getWidth = (): number => { + return obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2; + }; + + // Handlers + const handleDelete = useCallback(() => { + if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) { + provides.deleteAnnotation(pageIndex, annotationId); + } + }, [provides, annotationId, pageIndex]); + + const handleOpenTextEditor = useCallback(() => { + if (!annotation) return; + + // Try to find the annotation element in the DOM + const annotationElement = document.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement; + + let fontSize = (obj?.fontSize || 14) * 1.33; + let fontFamily = 'Helvetica'; + + if (annotationElement) { + const rect = annotationElement.getBoundingClientRect(); + + // Try multiple selectors to find the text element + const textElement = annotationElement.querySelector('text, [class*="text"], [class*="content"]') as HTMLElement; + if (textElement) { + const computedStyle = window.getComputedStyle(textElement); + const computedSize = parseFloat(computedStyle.fontSize); + if (computedSize && computedSize > 0) { + fontSize = computedSize; + } + fontFamily = computedStyle.fontFamily || fontFamily; + } + + setTextBoxPosition({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + fontSize: fontSize, + fontFamily: fontFamily, + }); + } else if (wrapperRef.current) { + // Fallback to wrapper position + const rect = wrapperRef.current.getBoundingClientRect(); + setTextBoxPosition({ + top: rect.top, + left: rect.left, + width: Math.max(rect.width, 200), + height: Math.max(rect.height, 50), + fontSize: fontSize, + fontFamily: fontFamily, + }); + } else { + return; + } + + setTextDraft(obj?.contents || ''); + setIsTextEditorOpen(true); + + // Focus the textarea after it renders + setTimeout(() => { + textareaRef.current?.focus(); + textareaRef.current?.select(); + }, 0); + }, [obj, annotation, annotationId]); + + const handleSaveText = useCallback(() => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, { + contents: textDraft, + }); + setIsTextEditorOpen(false); + setTextBoxPosition(null); + }, [provides, annotationId, pageIndex, textDraft]); + + const handleCloseTextEdit = useCallback(() => { + setIsTextEditorOpen(false); + setTextBoxPosition(null); + }, []); + + const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + const type = obj?.type; + const patch: any = {}; + + if (target === 'stroke') { + // Shape stroke - preserve fill color + patch.strokeColor = color; + patch.color = obj?.color || '#0000ff'; // Preserve fill + patch.strokeWidth = getWidth(); + } else if (target === 'fill') { + // Shape fill - preserve stroke color + patch.color = color; + patch.strokeColor = obj?.strokeColor || '#000000'; // Preserve stroke + patch.strokeWidth = getWidth(); + } else if (target === 'background') { + // Background color for text/note - set multiple properties for compatibility + patch.backgroundColor = color; + patch.fillColor = color; + patch.color = color; + } else if (target === 'text') { + // Text color for text/note - TRY PROPERTY COMBINATIONS + patch.textColor = color; + patch.fontColor = color; // EmbedPDF might expect this instead + + // Include font metadata (EmbedPDF might require these together) + patch.fontSize = obj?.fontSize ?? 14; + patch.fontFamily = obj?.fontFamily ?? 'Helvetica'; + + // Re-submit text content + patch.contents = obj?.contents ?? ''; + } else { + // Main color - for highlights, ink, etc. + patch.color = color; + + // For text markup annotations (highlight, underline, strikeout, squiggly) + if ([9, 10, 11, 12].includes(type)) { + patch.strokeColor = color; + patch.fillColor = color; + patch.opacity = obj?.opacity ?? 1; + } + + // For line annotations (type 4, 8), include stroke properties + if ([4, 8].includes(type)) { + patch.strokeColor = color; + patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2; + patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2; + } + + // For ink annotations (type 15), include all stroke-related properties + if (type === 15) { + patch.strokeColor = color; + patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2; + patch.opacity = obj?.opacity ?? 1; + } + } + + provides.updateAnnotation(pageIndex, annotationId, patch); + }, [provides, annotationId, pageIndex, obj]); + + const handleOpacityChange = useCallback((opacity: number) => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, { + opacity: opacity / 100, + }); + }, [provides, annotationId, pageIndex]); + + const handleWidthChange = useCallback((width: number) => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, { + strokeWidth: width, + }); + }, [provides, annotationId, pageIndex]); + + const handlePropertiesUpdate = useCallback((patch: Record) => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, patch); + }, [provides, annotationId, pageIndex]); + + // Render button groups based on annotation type + const renderButtons = () => { + const commonButtonStyles = { + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }; + + const EditTextButton = () => ( + + + + + + ); + + const DeleteButton = () => ( + + + + + + ); + + switch (annotationType) { + case 'textMarkup': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'ink': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'inkHighlighter': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + + ); + + case 'text': + case 'note': + return ( + <> + handleColorChange(color, 'text')} + label={t('annotation.color', 'Color')} + /> + handleColorChange(color, 'background')} + label={t('annotation.backgroundColor', 'Background color')} + /> + + + + + ); + + case 'shape': + return ( + <> + handleColorChange(color, 'stroke')} + label={t('annotation.strokeColor', 'Stroke Colour')} + /> + handleColorChange(color, 'fill')} + label={t('annotation.fillColor', 'Fill Colour')} + /> + + + + ); + + case 'line': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'stamp': + return ; + + default: + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + ); + } + }; + + // Calculate position for portal based on wrapper element + useEffect(() => { + if (!selected || !annotation || !wrapperRef.current) { + setMenuPosition(null); + return; + } + + const updatePosition = () => { + const wrapper = wrapperRef.current; + if (!wrapper) { + setMenuPosition(null); + return; + } + + const wrapperRect = wrapper.getBoundingClientRect(); + // Position menu below the wrapper, centered + // Use getBoundingClientRect which gives viewport-relative coordinates + // Since we're using fixed positioning in the portal, we don't need to add scroll offsets + setMenuPosition({ + top: wrapperRect.bottom + 8, + left: wrapperRect.left + wrapperRect.width / 2, + }); + }; + + updatePosition(); + + // Update position on scroll/resize + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [selected, annotation]); + + // Early return AFTER all hooks have been called + if (!selected || !annotation) return null; + + const menuContent = menuPosition ? ( +
+ + {renderButtons()} + +
+ ) : null; + + const textEditorOverlay = isTextEditorOpen && textBoxPosition ? ( +
+