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