JWT enhancements for desktop (#5742)

# Description of Changes

This is temporary solution which will be enhanced in future

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-02-16 21:57:42 +00:00
committed by GitHub
parent da2eb54fe8
commit 558c75a2b1
34 changed files with 1767 additions and 214 deletions
@@ -0,0 +1,49 @@
package stirling.software.common.constants;
/**
* Centralized constants for JWT token management.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>Prevents abuse of expired tokens by limiting refresh attempts.
*/
public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3;
}
@@ -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.
*
* <p><b>BREAKING CHANGE (v2.0):</b> 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.
*
* <p>Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours).
*
* <p><b>BREAKING CHANGE:</b> 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).
*
* <p>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).
*
* <p>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.
*
* <p>Default: 43200 minutes (30 days).
*/
private int desktopTokenExpiryMinutes = 43200;
/**
* Allowed clock skew in seconds for JWT validation.
*
* <p>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.
*
* <p>Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds.
*/
private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
/**
* Grace period in minutes for refreshing expired tokens.
*
* <p>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.
*
* <p>Rate limiting is applied to prevent abuse of expired tokens within the grace
* window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts).
*
* <p>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.
*
* <p>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.
*
* <p>Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440)
*
* <p>The buffer includes:
*
* <ul>
* <li>10% of token lifetime (scales with token duration)
* <li>Token refresh grace period ({@link #refreshGraceMinutes})
* <li>Clock skew tolerance ({@link #allowedClockSkewSeconds} converted to minutes)
* </ul>
*
* @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
@@ -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")
@@ -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)
@@ -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()
@@ -361,7 +361,8 @@ public class SecurityConfiguration {
securityProperties.getOauth2(),
userService,
jwtService,
licenseSettingsService))
licenseSettingsService,
applicationProperties))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
@@ -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<String, Object> 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<String, Object> claims = new HashMap<>();
claims.put("authType", user.getAuthenticationType());
claims.put("role", user.getRolesAsString());
Map<String, Object> 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<String, Object> 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.
*
* <p>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)
@@ -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<String, Object> 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 =
@@ -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<String, Object> 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 =
@@ -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<String, Object> claims, int expiryMinutes) {
try {
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
Optional<KeyPair> 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<String, Object> extractClaims(String token) {
Claims claims = extractAllClaims(token);
return new HashMap<>(claims);
}
@Override
public Map<String, Object> 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> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
final Claims claims = extractAllClaims(token, false);
return claimsResolver.apply(claims);
}
private <T> T extractClaim(
String token, Function<Claims, T> 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.
*
* <p>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<String, Object> header =
OBJECT_MAPPER.readValue(
headerBytes, new TypeReference<Map<String, Object>>() {});
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;
}
}
@@ -25,6 +25,16 @@ public interface JwtServiceInterface {
*/
String generateToken(String username, Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> extractClaimsAllowExpired(String token);
/**
* Check if token is expired
*
@@ -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.
*
* <p>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<Path> 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.
*
* <p>Private key stored as: keyId.key
*
* <p>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.
*
* <p>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());
}
@@ -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.
*
* <p>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<String, RefreshAttempt> 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<String, Object> getStats() {
return Map.of(
"tracked_tokens",
attempts.size(),
"max_attempts_allowed",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
}
}
@@ -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.
*
* <p>Desktop clients (Tauri, Electron) receive longer-lived tokens because:
*
* <ul>
* <li>They run on personal devices (not shared computers)
* <li>Tokens stored in OS-level encrypted keychain (not browser localStorage)
* <li>Better UX (users expect desktop apps to stay logged in)
* </ul>
*/
@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;
}
}
@@ -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("[email protected]");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "[email protected]");
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("[email protected]")).thenReturn(user);
when(jwtService.generateToken(eq("[email protected]"), 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<String, Object> claims = new HashMap<>();
claims.put("sub", "[email protected]");
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<String, Object> claims = new HashMap<>();
claims.put("sub", "[email protected]");
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("[email protected]")).thenReturn(user);
when(jwtService.generateToken(eq("[email protected]"), 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<String, Object> claims = new HashMap<>();
claims.put("sub", "[email protected]");
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
@@ -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);
@@ -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);