From 63eceb496fd49acfb7b4b9fbcc902100e8b17852 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:06:09 +0000 Subject: [PATCH 1/3] changes to 1 page = viewer --- frontend/src-tauri/tauri.conf.json | 6 ++++-- frontend/src/core/pages/HomePage.tsx | 27 ++++++++++----------------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 0da39ba5a6..1dee578dd1 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -13,8 +13,10 @@ "windows": [ { "title": "Stirling-PDF", - "width": 1280, - "height": 800, + "width": 1600, + "height": 1000, + "minWidth": 1100, + "minHeight": 700, "resizable": true, "fullscreen": false, "additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature" diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index f99cbfc8b0..721f109502 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -10,7 +10,7 @@ import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useLogoPath } from "@app/hooks/useLogoPath"; import { useLogoAssets } from '@app/hooks/useLogoAssets'; import { useFileContext } from "@app/contexts/file/fileHooks"; -import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext"; +import { useNavigationActions } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import AppsIcon from '@mui/icons-material/AppsRounded'; @@ -55,27 +55,22 @@ export default function HomePage() { const [configModalOpen, setConfigModalOpen] = useState(false); const { activeFiles } = useFileContext(); - const navigationState = useNavigationState(); const { actions } = useNavigationActions(); const { setActiveFileIndex } = useViewer(); - const prevFileCountRef = useRef(activeFiles.length); + const prevFileCountRef = useRef(0); - // Auto-switch to viewer when going from 0 to 1 file - // Skip this if PDF Text Editor is active - it handles its own empty state + // Startup/open transition behavior: + // - opening exactly 1 file from empty -> viewer + // - opening 2+ files from empty -> fileEditor useEffect(() => { const prevCount = prevFileCountRef.current; const currentCount = activeFiles.length; - if ( - navigationState.workbench !== 'fileEditor' && - prevCount === 0 && - currentCount === 1 - ) { - // PDF Text Editor handles its own empty state with a dropzone - if (selectedToolKey !== 'pdfTextEditor') { - actions.setWorkbench('viewer'); - setActiveFileIndex(0); - } + if (prevCount === 0 && currentCount === 1) { + actions.setWorkbench('viewer'); + setActiveFileIndex(0); + } else if (prevCount === 0 && currentCount > 1) { + actions.setWorkbench('fileEditor'); } prevFileCountRef.current = currentCount; @@ -83,8 +78,6 @@ export default function HomePage() { activeFiles.length, actions, setActiveFileIndex, - selectedToolKey, - navigationState.workbench, ]); const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); From 67ecba151969d392ef3450a6095aa9b4b1c758ac Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sun, 15 Feb 2026 01:00:50 +0000 Subject: [PATCH 2/3] fixes --- .../common/model/ApplicationProperties.java | 4 +- .../src/main/resources/settings.yml.template | 4 +- .../controller/api/AuthController.java | 28 ++++++++-- .../security/service/JwtService.java | 53 +++++++++++++++++-- .../security/service/JwtServiceInterface.java | 18 +++++++ frontend/src/desktop/services/authService.ts | 9 +++- 6 files changed, 105 insertions(+), 11 deletions(-) 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..37d3e589fe 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 @@ -398,7 +398,9 @@ public class ApplicationProperties { private boolean enableKeystore = true; private boolean enableKeyRotation = false; private boolean enableKeyCleanup = true; - private int keyRetentionDays = 7; + private int keyRetentionDays = 30; + private int tokenExpiryMinutes = 1440; + private int allowedClockSkewSeconds = 60; } @Data diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index a15da437d5..0fb505307b 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -64,7 +64,9 @@ 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. + keyRetentionDays: 30 # Number of days to retain old keys. The default is 30 days. + tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes (1 day). + allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift. 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/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index c7ebdbdb4f..7d68877eac 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 @@ -34,6 +34,7 @@ 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; @@ -180,7 +181,12 @@ public class AuthController { 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()))); } catch (UsernameNotFoundException e) { String username = request.getUsername(); @@ -272,8 +278,7 @@ public class AuthController { .body(Map.of("error", "No token found")); } - jwtService.validateToken(token); - String username = jwtService.extractUsername(token); + String username = jwtService.extractUsernameAllowExpired(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); User user = (User) userDetails; @@ -289,8 +294,17 @@ public class AuthController { 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()))); + } catch (AuthenticationFailureException e) { + log.error("Token refresh error", e); + 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 +546,12 @@ public class AuthController { return userMap; } + private long getTokenExpirySeconds() { + int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes(); + int expiryMinutes = configuredMinutes > 0 ? configuredMinutes : 720; + return expiryMinutes * 60L; + } + 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/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 60472fef42..cecb609f72 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 @@ -30,6 +30,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +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; @@ -39,17 +40,21 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin public class JwtService implements JwtServiceInterface { private static final String ISSUER = "https://stirling.com"; - private static final long EXPIRATION = 43200000; + private static final long ONE_MINUTE_MILLIS = 60_000L; + private static final int DEFAULT_EXPIRY_MINUTES = 720; 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 @@ -86,7 +91,8 @@ public class JwtService implements JwtServiceInterface { .subject(username) .issuer(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(); @@ -114,12 +120,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 +147,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; @@ -181,6 +208,7 @@ public class JwtService implements JwtServiceInterface { return Jwts.parser() .verifyWith(keyPair.getPublic()) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); @@ -191,6 +219,9 @@ public class JwtService implements JwtServiceInterface { log.warn("Invalid token: {}", e.getMessage()); throw new AuthenticationFailureException("Invalid token", e); } catch (ExpiredJwtException e) { + if (allowExpired) { + return e.getClaims(); + } log.warn("The token has expired: {}", e.getMessage()); throw new AuthenticationFailureException("The token has expired", e); } catch (UnsupportedJwtException e) { @@ -210,6 +241,7 @@ public class JwtService implements JwtServiceInterface { keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey()); return Jwts.parser() .verifyWith(publicKey) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); @@ -230,6 +262,7 @@ public class JwtService implements JwtServiceInterface { verificationKey.getVerifyingKey()); return Jwts.parser() .verifyWith(publicKey) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); @@ -276,6 +309,7 @@ public class JwtService implements JwtServiceInterface { (String) Jwts.parser() .verifyWith(signingKey) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parse(token) .getHeader() @@ -286,4 +320,15 @@ public class JwtService implements JwtServiceInterface { return null; } } + + private long getExpirationMillis() { + int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes(); + int expiryMinutes = configuredMinutes > 0 ? configuredMinutes : DEFAULT_EXPIRY_MINUTES; + return expiryMinutes * ONE_MINUTE_MILLIS; + } + + private long getAllowedClockSkewSeconds() { + int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds(); + return configuredSeconds >= 0 ? configuredSeconds : 60L; + } } 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..311f046a11 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 @@ -41,6 +41,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 +58,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/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index b3f3da9114..b410e05d0d 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -542,7 +542,14 @@ export class AuthService { } ); - const { token } = response.data; + const token = response.data?.session?.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); From 024d67b393b9a80794893fe9a6cb568ef5643202 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sun, 15 Feb 2026 01:12:54 +0000 Subject: [PATCH 3/3] build --- .../security/controller/api/AuthControllerLoginTest.java | 3 ++- .../software/proprietary/security/service/JwtServiceTest.java | 4 +++- frontend/src-tauri/tauri.conf.json | 2 +- frontend/src/core/testing/serverExperienceSimulations.ts | 2 +- .../shared/config/configSections/AdminSecuritySection.tsx | 2 +- .../src/proprietary/testing/serverExperienceSimulations.ts | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) 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..57aefeaa25 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 @@ -58,6 +58,7 @@ class AuthControllerLoginTest { void setUp() { securityProperties = new ApplicationProperties.Security(); securityProperties.setLoginMethod("all"); + securityProperties.getJwt().setTokenExpiryMinutes(60); AuthController controller = new AuthController( @@ -175,7 +176,7 @@ class AuthControllerLoginTest { void refreshReturnsNewTokenWhenValid() throws Exception { User user = buildUser(); when(jwtService.extractToken(any())).thenReturn("old"); - when(jwtService.extractUsername("old")).thenReturn("user@example.com"); + when(jwtService.extractUsernameAllowExpired("old")).thenReturn("user@example.com"); when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user); when(jwtService.generateToken(eq("user@example.com"), any(Map.class))) .thenReturn("new-token"); 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..f8083d0246 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 diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 1dee578dd1..3fb356c753 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.4.5", "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..b1ea3bcfe2 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.4.5', serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx index e5c1f41caf..881ae2e242 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx @@ -392,7 +392,7 @@ export default function AdminSecuritySection() { } description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')} - value={settings?.jwt?.keyRetentionDays || 7} + value={settings?.jwt?.keyRetentionDays || 30} onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, keyRetentionDays: Number(value) } })} min={1} max={365} diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts index dad9866f3c..faaf2181cd 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.4.5', serverCertificateEnabled: false, enableAlphaFunctionality: false, enableDesktopInstallSlide: true,