mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
@@ -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.InstallationPathConfig;
|
||||||
import stirling.software.common.configuration.YamlPropertySourceFactory;
|
import stirling.software.common.configuration.YamlPropertySourceFactory;
|
||||||
|
import stirling.software.common.constants.JwtConstants;
|
||||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||||
import stirling.software.common.model.oauth2.GitHubProvider;
|
import stirling.software.common.model.oauth2.GitHubProvider;
|
||||||
import stirling.software.common.model.oauth2.GoogleProvider;
|
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
|
@Data
|
||||||
public static class Jwt {
|
public static class Jwt {
|
||||||
private boolean enableKeystore = true;
|
private boolean enableKeystore = true;
|
||||||
private boolean enableKeyRotation = false;
|
private boolean enableKeyRotation = false;
|
||||||
private boolean enableKeyCleanup = true;
|
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
|
@Data
|
||||||
|
|||||||
+2
-1
@@ -426,7 +426,8 @@ public class PdfJsonFallbackFontService {
|
|||||||
String normalized =
|
String normalized =
|
||||||
WHITESPACE_PATTERN
|
WHITESPACE_PATTERN
|
||||||
.matcher(
|
.matcher(
|
||||||
PATTERN.matcher(originalFontName).replaceAll("") // Remove subset prefix
|
PATTERN.matcher(originalFontName)
|
||||||
|
.replaceAll("") // Remove subset prefix
|
||||||
.toLowerCase())
|
.toLowerCase())
|
||||||
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
|
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
|
||||||
// "timesnewroman")
|
// "timesnewroman")
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ security:
|
|||||||
persistence: true # Set to 'true' to enable JWT key store
|
persistence: true # Set to 'true' to enable JWT key store
|
||||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
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
|
validation: # PDF signature validation settings
|
||||||
trust:
|
trust:
|
||||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||||
|
|||||||
+10
-3
@@ -2,7 +2,7 @@ package stirling.software.proprietary.security.configuration;
|
|||||||
|
|
||||||
import java.time.Duration;
|
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.CacheManager;
|
||||||
import org.springframework.cache.annotation.EnableCaching;
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||||
@@ -11,15 +11,22 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
|
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableCaching
|
@EnableCaching
|
||||||
public class CacheConfig {
|
public class CacheConfig {
|
||||||
|
|
||||||
@Value("${security.jwt.keyRetentionDays}")
|
private final ApplicationProperties applicationProperties;
|
||||||
private int keyRetentionDays;
|
|
||||||
|
@Autowired
|
||||||
|
public CacheConfig(ApplicationProperties applicationProperties) {
|
||||||
|
this.applicationProperties = applicationProperties;
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CacheManager cacheManager() {
|
public CacheManager cacheManager() {
|
||||||
|
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
|
||||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
|
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
|
||||||
cacheManager.setCaffeine(
|
cacheManager.setCaffeine(
|
||||||
Caffeine.newBuilder()
|
Caffeine.newBuilder()
|
||||||
|
|||||||
+2
-1
@@ -361,7 +361,8 @@ public class SecurityConfiguration {
|
|||||||
securityProperties.getOauth2(),
|
securityProperties.getOauth2(),
|
||||||
userService,
|
userService,
|
||||||
jwtService,
|
jwtService,
|
||||||
licenseSettingsService))
|
licenseSettingsService,
|
||||||
|
applicationProperties))
|
||||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||||
// Add existing Authorities from the database
|
// Add existing Authorities from the database
|
||||||
.userInfoEndpoint(
|
.userInfoEndpoint(
|
||||||
|
|||||||
+208
-10
@@ -26,6 +26,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.common.constants.JwtConstants;
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.proprietary.audit.AuditEventType;
|
import stirling.software.proprietary.audit.AuditEventType;
|
||||||
import stirling.software.proprietary.audit.AuditLevel;
|
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.User;
|
||||||
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
||||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
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.CustomUserDetailsService;
|
||||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||||
import stirling.software.proprietary.security.service.MfaService;
|
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.TotpService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||||
|
|
||||||
/** REST API Controller for authentication operations. */
|
/** REST API Controller for authentication operations. */
|
||||||
@RestController
|
@RestController
|
||||||
@@ -55,7 +59,9 @@ public class AuthController {
|
|||||||
private final LoginAttemptService loginAttemptService;
|
private final LoginAttemptService loginAttemptService;
|
||||||
private final MfaService mfaService;
|
private final MfaService mfaService;
|
||||||
private final TotpService totpService;
|
private final TotpService totpService;
|
||||||
|
private final RefreshRateLimitService refreshRateLimitService;
|
||||||
private final ApplicationProperties.Security securityProperties;
|
private final ApplicationProperties.Security securityProperties;
|
||||||
|
private final ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login endpoint - replaces Supabase signInWithPassword
|
* Login endpoint - replaces Supabase signInWithPassword
|
||||||
@@ -171,16 +177,52 @@ public class AuthController {
|
|||||||
claims.put("authType", AuthenticationType.WEB.toString());
|
claims.put("authType", AuthenticationType.WEB.toString());
|
||||||
claims.put("role", user.getRolesAsString());
|
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
|
// Record successful login
|
||||||
loginAttemptService.loginSucceeded(username);
|
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(
|
return ResponseEntity.ok(
|
||||||
Map.of(
|
Map.of(
|
||||||
"user", buildUserResponse(user),
|
"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) {
|
} catch (UsernameNotFoundException e) {
|
||||||
String username = request.getUsername();
|
String username = request.getUsername();
|
||||||
@@ -272,25 +314,92 @@ public class AuthController {
|
|||||||
.body(Map.of("error", "No token found"));
|
.body(Map.of("error", "No token found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
jwtService.validateToken(token);
|
// Generate token hash for rate limiting (avoid storing actual tokens)
|
||||||
String username = jwtService.extractUsername(token);
|
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);
|
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
User user = (User) userDetails;
|
User user = (User) userDetails;
|
||||||
|
|
||||||
Map<String, Object> claims = new HashMap<>();
|
Map<String, Object> newClaims = new HashMap<>();
|
||||||
claims.put("authType", user.getAuthenticationType());
|
newClaims.put("authType", user.getAuthenticationType());
|
||||||
claims.put("role", user.getRolesAsString());
|
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);
|
log.debug("Token refreshed for user: {}", username);
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
Map.of(
|
Map.of(
|
||||||
"user", buildUserResponse(user),
|
"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) {
|
} catch (Exception e) {
|
||||||
log.error("Token refresh error", e);
|
log.error("Token refresh error", e);
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
@@ -532,6 +641,95 @@ public class AuthController {
|
|||||||
return userMap;
|
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) {
|
private ResponseEntity<?> ensureWebAuth(User user) {
|
||||||
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
|||||||
+23
-3
@@ -36,6 +36,7 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
|||||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -48,6 +49,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
|||||||
private final JwtServiceInterface jwtService;
|
private final JwtServiceInterface jwtService;
|
||||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||||
licenseSettingsService;
|
licenseSettingsService;
|
||||||
|
private final ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||||
@@ -150,9 +152,27 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
|||||||
|
|
||||||
// Generate JWT if v2 is enabled
|
// Generate JWT if v2 is enabled
|
||||||
if (jwtService.isJwtEnabled()) {
|
if (jwtService.isJwtEnabled()) {
|
||||||
String jwt =
|
Map<String, Object> claims = Map.of("authType", AuthenticationType.OAUTH2);
|
||||||
jwtService.generateToken(
|
|
||||||
authentication, 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
|
// Build context-aware redirect URL based on the original request
|
||||||
String redirectUrl =
|
String redirectUrl =
|
||||||
|
|||||||
+22
-4
@@ -37,6 +37,7 @@ import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
|
|||||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||||
|
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -191,10 +192,27 @@ public class CustomSaml2AuthenticationSuccessHandler
|
|||||||
|
|
||||||
// Generate JWT if v2 is enabled
|
// Generate JWT if v2 is enabled
|
||||||
if (jwtService.isJwtEnabled()) {
|
if (jwtService.isJwtEnabled()) {
|
||||||
String jwt =
|
Map<String, Object> claims = Map.of("authType", AuthenticationType.SAML2);
|
||||||
jwtService.generateToken(
|
|
||||||
authentication,
|
// Detect desktop client and issue longer-lived tokens
|
||||||
Map.of("authType", AuthenticationType.SAML2));
|
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
|
// Build context-aware redirect URL based on the original request
|
||||||
String redirectUrl =
|
String redirectUrl =
|
||||||
|
|||||||
+137
-23
@@ -5,6 +5,7 @@ import java.security.NoSuchAlgorithmException;
|
|||||||
import java.security.PublicKey;
|
import java.security.PublicKey;
|
||||||
import java.security.spec.InvalidKeySpecException;
|
import java.security.spec.InvalidKeySpecException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
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.security.oauth2.core.user.OAuth2User;
|
||||||
import org.springframework.stereotype.Service;
|
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.Claims;
|
||||||
import io.jsonwebtoken.ExpiredJwtException;
|
import io.jsonwebtoken.ExpiredJwtException;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
@@ -30,6 +34,8 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.JwtVerificationKey;
|
||||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||||
@@ -38,18 +44,20 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
|||||||
@Service
|
@Service
|
||||||
public class JwtService implements JwtServiceInterface {
|
public class JwtService implements JwtServiceInterface {
|
||||||
|
|
||||||
private static final String ISSUER = "https://stirling.com";
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
private static final long EXPIRATION = 43200000;
|
|
||||||
|
|
||||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||||
private final boolean v2Enabled;
|
private final boolean v2Enabled;
|
||||||
|
private final ApplicationProperties.Security securityProperties;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public JwtService(
|
public JwtService(
|
||||||
@Qualifier("v2Enabled") boolean v2Enabled,
|
@Qualifier("v2Enabled") boolean v2Enabled,
|
||||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
KeyPersistenceServiceInterface keyPersistenceService,
|
||||||
|
ApplicationProperties applicationProperties) {
|
||||||
this.v2Enabled = v2Enabled;
|
this.v2Enabled = v2Enabled;
|
||||||
this.keyPersistenceService = keyPersistenceService;
|
this.keyPersistenceService = keyPersistenceService;
|
||||||
|
this.securityProperties = applicationProperties.getSecurity();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -84,9 +92,10 @@ public class JwtService implements JwtServiceInterface {
|
|||||||
Jwts.builder()
|
Jwts.builder()
|
||||||
.claims(claims)
|
.claims(claims)
|
||||||
.subject(username)
|
.subject(username)
|
||||||
.issuer(ISSUER)
|
.issuer(JwtConstants.ISSUER)
|
||||||
.issuedAt(new Date())
|
.issuedAt(new Date())
|
||||||
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
|
.expiration(
|
||||||
|
new Date(System.currentTimeMillis() + getExpirationMillis()))
|
||||||
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
||||||
|
|
||||||
String keyId = activeKey.getKeyId();
|
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
|
@Override
|
||||||
public void validateToken(String token) throws AuthenticationFailureException {
|
public void validateToken(String token) throws AuthenticationFailureException {
|
||||||
extractAllClaims(token);
|
extractAllClaims(token);
|
||||||
@@ -114,12 +157,23 @@ public class JwtService implements JwtServiceInterface {
|
|||||||
return extractClaim(token, Claims::getSubject);
|
return extractClaim(token, Claims::getSubject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String extractUsernameAllowExpired(String token) {
|
||||||
|
return extractClaim(token, Claims::getSubject, true);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> extractClaims(String token) {
|
public Map<String, Object> extractClaims(String token) {
|
||||||
Claims claims = extractAllClaims(token);
|
Claims claims = extractAllClaims(token);
|
||||||
return new HashMap<>(claims);
|
return new HashMap<>(claims);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> extractClaimsAllowExpired(String token) {
|
||||||
|
Claims claims = extractAllClaims(token, true);
|
||||||
|
return new HashMap<>(claims);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean isTokenExpired(String token) {
|
public boolean isTokenExpired(String token) {
|
||||||
return extractExpiration(token).before(new Date());
|
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) {
|
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);
|
return claimsResolver.apply(claims);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Claims extractAllClaims(String token) {
|
private Claims extractAllClaims(String token) {
|
||||||
|
return extractAllClaims(token, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Claims extractAllClaims(String token, boolean allowExpired) {
|
||||||
try {
|
try {
|
||||||
String keyId = extractKeyId(token);
|
String keyId = extractKeyId(token);
|
||||||
KeyPair keyPair;
|
KeyPair keyPair;
|
||||||
@@ -176,11 +240,12 @@ public class JwtService implements JwtServiceInterface {
|
|||||||
} else {
|
} else {
|
||||||
log.debug("No key ID in token header, trying all available keys");
|
log.debug("No key ID in token header, trying all available keys");
|
||||||
// Try all available keys when no keyId is present
|
// Try all available keys when no keyId is present
|
||||||
return tryAllKeys(token);
|
return tryAllKeys(token, allowExpired);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Jwts.parser()
|
return Jwts.parser()
|
||||||
.verifyWith(keyPair.getPublic())
|
.verifyWith(keyPair.getPublic())
|
||||||
|
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||||
.build()
|
.build()
|
||||||
.parseSignedClaims(token)
|
.parseSignedClaims(token)
|
||||||
.getPayload();
|
.getPayload();
|
||||||
@@ -191,7 +256,13 @@ public class JwtService implements JwtServiceInterface {
|
|||||||
log.warn("Invalid token: {}", e.getMessage());
|
log.warn("Invalid token: {}", e.getMessage());
|
||||||
throw new AuthenticationFailureException("Invalid token", e);
|
throw new AuthenticationFailureException("Invalid token", e);
|
||||||
} catch (ExpiredJwtException 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);
|
throw new AuthenticationFailureException("The token has expired", e);
|
||||||
} catch (UnsupportedJwtException e) {
|
} catch (UnsupportedJwtException e) {
|
||||||
log.warn("The token is unsupported: {}", e.getMessage());
|
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
|
// First try the active key
|
||||||
try {
|
try {
|
||||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||||
@@ -210,9 +282,18 @@ public class JwtService implements JwtServiceInterface {
|
|||||||
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
|
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
|
||||||
return Jwts.parser()
|
return Jwts.parser()
|
||||||
.verifyWith(publicKey)
|
.verifyWith(publicKey)
|
||||||
|
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||||
.build()
|
.build()
|
||||||
.parseSignedClaims(token)
|
.parseSignedClaims(token)
|
||||||
.getPayload();
|
.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
|
} catch (SignatureException
|
||||||
| NoSuchAlgorithmException
|
| NoSuchAlgorithmException
|
||||||
| InvalidKeySpecException activeKeyException) {
|
| InvalidKeySpecException activeKeyException) {
|
||||||
@@ -230,9 +311,15 @@ public class JwtService implements JwtServiceInterface {
|
|||||||
verificationKey.getVerifyingKey());
|
verificationKey.getVerifyingKey());
|
||||||
return Jwts.parser()
|
return Jwts.parser()
|
||||||
.verifyWith(publicKey)
|
.verifyWith(publicKey)
|
||||||
|
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||||
.build()
|
.build()
|
||||||
.parseSignedClaims(token)
|
.parseSignedClaims(token)
|
||||||
.getPayload();
|
.getPayload();
|
||||||
|
} catch (ExpiredJwtException e) {
|
||||||
|
if (allowExpired) {
|
||||||
|
return e.getClaims();
|
||||||
|
}
|
||||||
|
throw new AuthenticationFailureException("The token has expired", e);
|
||||||
} catch (SignatureException
|
} catch (SignatureException
|
||||||
| NoSuchAlgorithmException
|
| NoSuchAlgorithmException
|
||||||
| InvalidKeySpecException e) {
|
| InvalidKeySpecException e) {
|
||||||
@@ -266,24 +353,51 @@ public class JwtService implements JwtServiceInterface {
|
|||||||
return v2Enabled;
|
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) {
|
private String extractKeyId(String token) {
|
||||||
try {
|
try {
|
||||||
PublicKey signingKey =
|
String[] tokenParts = token.split("\\.");
|
||||||
keyPersistenceService.decodePublicKey(
|
if (tokenParts.length < 2) {
|
||||||
keyPersistenceService.getActiveKey().getVerifyingKey());
|
log.debug(
|
||||||
|
"Token does not have enough parts (expected at least 2, got {})",
|
||||||
|
tokenParts.length);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
String keyId =
|
byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]);
|
||||||
(String)
|
Map<String, Object> header =
|
||||||
Jwts.parser()
|
OBJECT_MAPPER.readValue(
|
||||||
.verifyWith(signingKey)
|
headerBytes, new TypeReference<Map<String, Object>>() {});
|
||||||
.build()
|
Object keyId = header.get("kid");
|
||||||
.parse(token)
|
return keyId instanceof String ? (String) keyId : null;
|
||||||
.getHeader()
|
} catch (IllegalArgumentException e) {
|
||||||
.get("kid");
|
log.debug("Failed to decode Base64 JWT header: {}", e.getMessage());
|
||||||
return keyId;
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (java.io.IOException e) {
|
||||||
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
|
log.debug("Failed to parse JWT header as JSON: {}", e.getMessage());
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -25,6 +25,16 @@ public interface JwtServiceInterface {
|
|||||||
*/
|
*/
|
||||||
String generateToken(String username, Map<String, Object> claims);
|
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
|
* Validate a JWT token
|
||||||
*
|
*
|
||||||
@@ -41,6 +51,15 @@ public interface JwtServiceInterface {
|
|||||||
*/
|
*/
|
||||||
String extractUsername(String token);
|
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
|
* Extract all claims from JWT token
|
||||||
*
|
*
|
||||||
@@ -49,6 +68,15 @@ public interface JwtServiceInterface {
|
|||||||
*/
|
*/
|
||||||
Map<String, Object> extractClaims(String token);
|
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
|
* Check if token is expired
|
||||||
*
|
*
|
||||||
|
|||||||
+191
-13
@@ -10,8 +10,10 @@ import java.security.KeyPairGenerator;
|
|||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.PrivateKey;
|
import java.security.PrivateKey;
|
||||||
import java.security.PublicKey;
|
import java.security.PublicKey;
|
||||||
|
import java.security.interfaces.RSAPrivateCrtKey;
|
||||||
import java.security.spec.InvalidKeySpecException;
|
import java.security.spec.InvalidKeySpecException;
|
||||||
import java.security.spec.PKCS8EncodedKeySpec;
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
import java.security.spec.RSAPublicKeySpec;
|
||||||
import java.security.spec.X509EncodedKeySpec;
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
@@ -41,6 +43,7 @@ import stirling.software.proprietary.security.model.JwtVerificationKey;
|
|||||||
public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||||
|
|
||||||
public static final String KEY_SUFFIX = ".key";
|
public static final String KEY_SUFFIX = ".key";
|
||||||
|
public static final String PUB_KEY_SUFFIX = ".pub";
|
||||||
|
|
||||||
private final ApplicationProperties.Security.Jwt jwtProperties;
|
private final ApplicationProperties.Security.Jwt jwtProperties;
|
||||||
private final CacheManager cacheManager;
|
private final CacheManager cacheManager;
|
||||||
@@ -59,19 +62,119 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
|||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void initializeKeystore() {
|
public void initializeKeystore() {
|
||||||
if (!isKeystoreEnabled()) {
|
if (!isKeystoreEnabled()) {
|
||||||
|
log.info("JWT keystore is disabled - keys will be generated in memory");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensurePrivateKeyDirectoryExists();
|
ensurePrivateKeyDirectoryExists();
|
||||||
loadKeyPair();
|
loadExistingKeysFromDisk();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to initialize keystore, using in-memory generation", 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();
|
generateAndStoreKeypair();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,10 +187,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
|||||||
KeyPair keyPair = generateRSAKeypair();
|
KeyPair keyPair = generateRSAKeypair();
|
||||||
String keyId = generateKeyId();
|
String keyId = generateKeyId();
|
||||||
|
|
||||||
storePrivateKey(keyId, keyPair.getPrivate());
|
storeKeyPair(keyId, keyPair);
|
||||||
verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic()));
|
verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic()));
|
||||||
verifyingKeyCache.put(keyId, verifyingKey);
|
verifyingKeyCache.put(keyId, verifyingKey);
|
||||||
activeKey = verifyingKey;
|
activeKey = verifyingKey;
|
||||||
|
log.info("Generated and stored new JWT keypair: {}", keyId);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Failed to generate and store keypair", 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 =
|
* Store both private and public keys to disk.
|
||||||
Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX);
|
*
|
||||||
String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded());
|
* <p>Private key stored as: keyId.key
|
||||||
Files.writeString(keyFile, encodedKey);
|
*
|
||||||
|
* <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
|
// Store private key
|
||||||
keyFile.toFile().setReadable(true, true);
|
Path privateKeyFile = keyDirectory.resolve(keyId + KEY_SUFFIX);
|
||||||
keyFile.toFile().setWritable(true, true);
|
String encodedPrivateKey =
|
||||||
keyFile.toFile().setExecutable(false, false);
|
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)
|
private PrivateKey loadPrivateKey(String keyId)
|
||||||
@@ -229,6 +360,53 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
|||||||
return keyFactory.generatePrivate(keySpec);
|
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) {
|
private String encodePublicKey(PublicKey publicKey) {
|
||||||
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||||
}
|
}
|
||||||
|
|||||||
+124
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
-3
@@ -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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
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.Map;
|
||||||
import java.util.Set;
|
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.JwtServiceInterface;
|
||||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||||
import stirling.software.proprietary.security.service.MfaService;
|
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.TotpService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
|
||||||
@@ -53,11 +56,17 @@ class AuthControllerLoginTest {
|
|||||||
@Mock private LoginAttemptService loginAttemptService;
|
@Mock private LoginAttemptService loginAttemptService;
|
||||||
@Mock private MfaService mfaService;
|
@Mock private MfaService mfaService;
|
||||||
@Mock private TotpService totpService;
|
@Mock private TotpService totpService;
|
||||||
|
@Mock private RefreshRateLimitService refreshRateLimitService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
securityProperties = new ApplicationProperties.Security();
|
securityProperties = new ApplicationProperties.Security();
|
||||||
securityProperties.setLoginMethod("all");
|
securityProperties.setLoginMethod("all");
|
||||||
|
securityProperties.getJwt().setTokenExpiryMinutes(60);
|
||||||
|
securityProperties.getJwt().setRefreshGraceMinutes(5);
|
||||||
|
|
||||||
|
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||||
|
applicationProperties.setSecurity(securityProperties);
|
||||||
|
|
||||||
AuthController controller =
|
AuthController controller =
|
||||||
new AuthController(
|
new AuthController(
|
||||||
@@ -67,7 +76,9 @@ class AuthControllerLoginTest {
|
|||||||
loginAttemptService,
|
loginAttemptService,
|
||||||
mfaService,
|
mfaService,
|
||||||
totpService,
|
totpService,
|
||||||
securityProperties);
|
refreshRateLimitService,
|
||||||
|
securityProperties,
|
||||||
|
applicationProperties);
|
||||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +186,11 @@ class AuthControllerLoginTest {
|
|||||||
void refreshReturnsNewTokenWhenValid() throws Exception {
|
void refreshReturnsNewTokenWhenValid() throws Exception {
|
||||||
User user = buildUser();
|
User user = buildUser();
|
||||||
when(jwtService.extractToken(any())).thenReturn("old");
|
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(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
|
||||||
when(jwtService.generateToken(eq("[email protected]"), any(Map.class)))
|
when(jwtService.generateToken(eq("[email protected]"), any(Map.class)))
|
||||||
.thenReturn("new-token");
|
.thenReturn("new-token");
|
||||||
@@ -184,7 +199,75 @@ class AuthControllerLoginTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.user").exists())
|
.andExpect(jsonPath("$.user").exists())
|
||||||
.andExpect(jsonPath("$.session.access_token").value("new-token"))
|
.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
|
@Test
|
||||||
|
|||||||
+7
-1
@@ -37,13 +37,19 @@ class CustomOAuth2AuthenticationSuccessHandlerTest {
|
|||||||
oauth2Props.setAutoCreateUser(true);
|
oauth2Props.setAutoCreateUser(true);
|
||||||
oauth2Props.setBlockRegistration(false);
|
oauth2Props.setBlockRegistration(false);
|
||||||
|
|
||||||
|
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||||
|
ApplicationProperties.Security securityProperties = new ApplicationProperties.Security();
|
||||||
|
securityProperties.setOauth2(oauth2Props);
|
||||||
|
applicationProperties.setSecurity(securityProperties);
|
||||||
|
|
||||||
CustomOAuth2AuthenticationSuccessHandler handler =
|
CustomOAuth2AuthenticationSuccessHandler handler =
|
||||||
new CustomOAuth2AuthenticationSuccessHandler(
|
new CustomOAuth2AuthenticationSuccessHandler(
|
||||||
loginAttemptService,
|
loginAttemptService,
|
||||||
oauth2Props,
|
oauth2Props,
|
||||||
userService,
|
userService,
|
||||||
jwtService,
|
jwtService,
|
||||||
licenseSettingsService);
|
licenseSettingsService,
|
||||||
|
applicationProperties);
|
||||||
|
|
||||||
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
|
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
|
||||||
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
|
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
|
||||||
|
|||||||
+3
-15
@@ -31,6 +31,7 @@ import org.springframework.security.core.Authentication;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||||
@@ -64,7 +65,8 @@ class JwtServiceTest {
|
|||||||
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
|
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
|
||||||
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
|
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
|
||||||
|
|
||||||
jwtService = new JwtService(true, keystoreService);
|
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||||
|
jwtService = new JwtService(true, keystoreService, applicationProperties);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -73,8 +75,6 @@ class JwtServiceTest {
|
|||||||
|
|
||||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
|
||||||
.thenReturn(testKeyPair.getPublic());
|
|
||||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||||
when(userDetails.getUsername()).thenReturn(username);
|
when(userDetails.getUsername()).thenReturn(username);
|
||||||
|
|
||||||
@@ -94,8 +94,6 @@ class JwtServiceTest {
|
|||||||
|
|
||||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
|
||||||
.thenReturn(testKeyPair.getPublic());
|
|
||||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||||
when(userDetails.getUsername()).thenReturn(username);
|
when(userDetails.getUsername()).thenReturn(username);
|
||||||
|
|
||||||
@@ -114,8 +112,6 @@ class JwtServiceTest {
|
|||||||
void testValidateTokenSuccess() throws Exception {
|
void testValidateTokenSuccess() throws Exception {
|
||||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
|
||||||
.thenReturn(testKeyPair.getPublic());
|
|
||||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||||
when(userDetails.getUsername()).thenReturn("testuser");
|
when(userDetails.getUsername()).thenReturn("testuser");
|
||||||
|
|
||||||
@@ -179,8 +175,6 @@ class JwtServiceTest {
|
|||||||
|
|
||||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
|
||||||
.thenReturn(testKeyPair.getPublic());
|
|
||||||
when(authentication.getPrincipal()).thenReturn(user);
|
when(authentication.getPrincipal()).thenReturn(user);
|
||||||
when(user.getUsername()).thenReturn(username);
|
when(user.getUsername()).thenReturn(username);
|
||||||
|
|
||||||
@@ -207,8 +201,6 @@ class JwtServiceTest {
|
|||||||
|
|
||||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
|
||||||
.thenReturn(testKeyPair.getPublic());
|
|
||||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||||
when(userDetails.getUsername()).thenReturn(username);
|
when(userDetails.getUsername()).thenReturn(username);
|
||||||
|
|
||||||
@@ -281,8 +273,6 @@ class JwtServiceTest {
|
|||||||
|
|
||||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
|
||||||
.thenReturn(testKeyPair.getPublic());
|
|
||||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||||
when(userDetails.getUsername()).thenReturn(username);
|
when(userDetails.getUsername()).thenReturn(username);
|
||||||
|
|
||||||
@@ -307,8 +297,6 @@ class JwtServiceTest {
|
|||||||
// First, generate a token successfully
|
// First, generate a token successfully
|
||||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
|
||||||
.thenReturn(testKeyPair.getPublic());
|
|
||||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||||
when(userDetails.getUsername()).thenReturn(username);
|
when(userDetails.getUsername()).thenReturn(username);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ springBoot {
|
|||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
group = 'stirling.software'
|
group = 'stirling.software'
|
||||||
version = '2.4.5'
|
version = '2.5.0'
|
||||||
|
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
exclude group: 'commons-logging', module: 'commons-logging'
|
exclude group: 'commons-logging', module: 'commons-logging'
|
||||||
|
|||||||
@@ -1236,9 +1236,21 @@ label = "Enable Key Cleanup"
|
|||||||
description = "Automatically rotate JWT signing keys periodically"
|
description = "Automatically rotate JWT signing keys periodically"
|
||||||
label = "Enable Key Rotation"
|
label = "Enable Key Rotation"
|
||||||
|
|
||||||
[admin.settings.security.jwt.keyRetentionDays]
|
[admin.settings.security.jwt.tokenExpiryMinutes]
|
||||||
description = "Number of days to retain old JWT keys for verification"
|
description = "Access token lifetime in minutes for web clients (default: 1440 = 24 hours)"
|
||||||
label = "Key Retention Days"
|
label = "Web Token Expiry (minutes)"
|
||||||
|
|
||||||
|
[admin.settings.security.jwt.desktopTokenExpiryMinutes]
|
||||||
|
description = "Access token lifetime in minutes for desktop clients. Desktop apps automatically detected via User-Agent and receive longer sessions for better UX (default: 43200 = 30 days)"
|
||||||
|
label = "Desktop Token Expiry (minutes)"
|
||||||
|
|
||||||
|
[admin.settings.security.jwt.allowedClockSkewSeconds]
|
||||||
|
description = "Tolerance for client/server time drift during token validation (default: 60 seconds)"
|
||||||
|
label = "Clock Skew Tolerance (seconds)"
|
||||||
|
|
||||||
|
[admin.settings.security.jwt.refreshGraceMinutes]
|
||||||
|
description = "Allow token refresh within this many minutes after expiry (default: 15 minutes, max 3 attempts)"
|
||||||
|
label = "Refresh Grace Period (minutes)"
|
||||||
|
|
||||||
[admin.settings.security.jwt.persistence]
|
[admin.settings.security.jwt.persistence]
|
||||||
description = "Store JWT keys persistently to survive server restarts"
|
description = "Store JWT keys persistently to survive server restarts"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "Stirling-PDF",
|
"productName": "Stirling-PDF",
|
||||||
"version": "2.4.6",
|
"version": "2.5.0",
|
||||||
"identifier": "stirling.pdf.dev",
|
"identifier": "stirling.pdf.dev",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
|||||||
|
|
||||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||||
enableAnalytics: true,
|
enableAnalytics: true,
|
||||||
appVersion: '2.4.6',
|
appVersion: '2.5.0',
|
||||||
serverCertificateEnabled: false,
|
serverCertificateEnabled: false,
|
||||||
enableAlphaFunctionality: false,
|
enableAlphaFunctionality: false,
|
||||||
serverPort: 8080,
|
serverPort: 8080,
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
|||||||
disabled={testing || loading}
|
disabled={testing || loading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCustomUrl(serverUrl);
|
setCustomUrl(serverUrl);
|
||||||
|
// Auto-submit the form after setting the URL
|
||||||
|
setTimeout(() => {
|
||||||
|
handleSubmit(new Event('submit') as any);
|
||||||
|
}, 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('setup.server.useLast', 'Last used server: {{serverUrl}}', { serverUrl: serverUrl })}
|
{t('setup.server.useLast', 'Last used server: {{serverUrl}}', { serverUrl: serverUrl })}
|
||||||
|
|||||||
@@ -13,7 +13,17 @@ export async function clearPlatformAuthAfterSignOut(): Promise<void> {
|
|||||||
|
|
||||||
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
|
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await authService.localClearAuth();
|
// Only clear if there's NO token in storage
|
||||||
|
// If token exists, user just logged in and we should keep it
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('stirling_jwt') : null;
|
||||||
|
console.log('[AuthCleanup] Login init check - token exists:', !!token, 'length:', token?.length || 0);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.log('[AuthCleanup] No token found on login init, clearing stale auth data');
|
||||||
|
await authService.localClearAuth();
|
||||||
|
} else {
|
||||||
|
console.log('[AuthCleanup] Token present on login init (length:', token.length, '), skipping cleanup (fresh login)');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[AuthCleanup] Failed to clear desktop auth data on login init', err);
|
console.warn('[AuthCleanup] Failed to clear desktop auth data on login init', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||||
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
import { authService } from '@app/services/authService';
|
||||||
|
import type { PlatformSessionUser } from '@proprietary/extensions/platformSessionBridge';
|
||||||
|
|
||||||
|
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
|
// Return true for ANY desktop auth mode (SaaS or self-hosted with desktop authService)
|
||||||
|
// This skips redundant backend validation in springAuthClient since desktop authService
|
||||||
|
// already manages the token lifecycle
|
||||||
|
return mode === 'saas' || mode === 'selfhosted';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
|
||||||
|
try {
|
||||||
|
const userInfo = await authService.getUserInfo();
|
||||||
|
if (!userInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
username: userInfo.username,
|
||||||
|
email: userInfo.email,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshPlatformSession(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
|
if (mode === 'saas') {
|
||||||
|
return await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
|
||||||
|
} else if (mode === 'selfhosted') {
|
||||||
|
const serverConfig = await connectionModeService.getServerConfig();
|
||||||
|
if (!serverConfig) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await authService.refreshToken(serverConfig.url);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save token to platform-specific secure storage (Tauri store + localStorage)
|
||||||
|
* Called after token refresh to ensure token is synced across all storage locations
|
||||||
|
*/
|
||||||
|
export async function savePlatformToken(token: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await authService.saveToken(token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PlatformBridge] Failed to save token:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ let lastBackendToast = 0;
|
|||||||
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
|
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
|
||||||
operationName?: string;
|
operationName?: string;
|
||||||
skipBackendReadyCheck?: boolean;
|
skipBackendReadyCheck?: boolean;
|
||||||
|
skipAuthRedirect?: boolean;
|
||||||
_retry?: boolean;
|
_retry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +56,10 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
// Self-hosted mode: enable credentials for session management
|
// Self-hosted mode: enable credentials for session management
|
||||||
extendedConfig.withCredentials = true;
|
extendedConfig.withCredentials = true;
|
||||||
|
|
||||||
|
// If another request is already refreshing, wait before attaching token.
|
||||||
|
await authService.awaitRefreshIfInProgress();
|
||||||
const token = await authService.getAuthToken();
|
const token = await authService.getAuthToken();
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
||||||
} else {
|
} else {
|
||||||
@@ -104,9 +108,16 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config as ExtendedRequestConfig;
|
const originalRequest = error.config as ExtendedRequestConfig;
|
||||||
|
const requestUrl = String(originalRequest?.url || '');
|
||||||
|
const isAuthProbeRequest = requestUrl.includes('/api/v1/auth/me');
|
||||||
|
|
||||||
// Handle 401 Unauthorized - try to refresh token
|
// Handle 401 Unauthorized - try to refresh token
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
// `/auth/me` is used as a probe by session bootstrap; refreshing here can
|
||||||
|
// create recursion (refresh -> save token -> jwt-available -> /auth/me).
|
||||||
|
if (isAuthProbeRequest) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
console.warn('[apiClientSetup] 401 on path:', window.location.pathname, 'url:', originalRequest.url);
|
console.warn('[apiClientSetup] 401 on path:', window.location.pathname, 'url:', originalRequest.url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export class AuthService {
|
|||||||
private authStatus: AuthStatus = 'unauthenticated';
|
private authStatus: AuthStatus = 'unauthenticated';
|
||||||
private userInfo: UserInfo | null = null;
|
private userInfo: UserInfo | null = null;
|
||||||
private cachedToken: string | null = null;
|
private cachedToken: string | null = null;
|
||||||
|
private lastTokenSaveTime: number = 0;
|
||||||
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
||||||
private refreshPromise: Promise<boolean> | null = null;
|
private refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
@@ -52,52 +53,51 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Save token to all storage locations and notify listeners
|
* Save token to all storage locations and notify listeners
|
||||||
*/
|
*/
|
||||||
private async saveTokenEverywhere(token: string, refreshToken?: string | null): Promise<void> {
|
private async saveTokenEverywhere(
|
||||||
|
token: string,
|
||||||
|
refreshToken?: string | null,
|
||||||
|
emitJwtAvailable = true
|
||||||
|
): Promise<void> {
|
||||||
// Validate token before caching
|
// Validate token before caching
|
||||||
if (!token || token.trim().length === 0) {
|
if (!token || token.trim().length === 0) {
|
||||||
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
|
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
|
||||||
throw new Error('Invalid token');
|
throw new Error('Invalid token');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Desktop AuthService] Saving token (length: ${token.length})`);
|
|
||||||
|
|
||||||
// Save access token to Tauri secure store (primary)
|
// Save access token to Tauri secure store (primary)
|
||||||
try {
|
try {
|
||||||
await invoke('save_auth_token', { token });
|
await invoke('save_auth_token', { token });
|
||||||
console.log('[Desktop AuthService] ✅ Token saved to Tauri store');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Desktop AuthService] ❌ Failed to save token to Tauri store:', error);
|
console.error('[Desktop AuthService] Failed to save token to Tauri store:', error);
|
||||||
// Don't throw - we can still use localStorage
|
// Don't throw - we can still use localStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync to localStorage for web layer (fallback)
|
// Sync to localStorage for web layer (fallback)
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('stirling_jwt', token);
|
localStorage.setItem('stirling_jwt', token);
|
||||||
console.log('[Desktop AuthService] ✅ Token saved to localStorage');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Desktop AuthService] ❌ Failed to save token to localStorage:', error);
|
console.error('[Desktop AuthService] Failed to save token to localStorage:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the valid token in memory
|
// Cache the valid token in memory
|
||||||
this.cachedToken = token;
|
this.cachedToken = token;
|
||||||
console.log('[Desktop AuthService] ✅ Token cached in memory');
|
this.lastTokenSaveTime = Date.now();
|
||||||
|
|
||||||
// Save refresh token if provided (keyring with Tauri Store fallback)
|
// Save refresh token if provided (keyring with Tauri Store fallback)
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
console.log('[Desktop AuthService] Saving refresh token to secure storage...');
|
|
||||||
try {
|
try {
|
||||||
await invoke('save_refresh_token', { token: refreshToken });
|
await invoke('save_refresh_token', { token: refreshToken });
|
||||||
console.log('[Desktop AuthService] ✅ Refresh token saved to secure storage');
|
|
||||||
// Only remove from localStorage after successful save
|
// Only remove from localStorage after successful save
|
||||||
localStorage.removeItem('stirling_refresh_token');
|
localStorage.removeItem('stirling_refresh_token');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Desktop AuthService] ❌ Failed to save refresh token:', error);
|
console.error('[Desktop AuthService] Failed to save refresh token:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify other parts of the system
|
if (emitJwtAvailable) {
|
||||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
// Notify other parts of the system when a brand-new auth session is established.
|
||||||
console.log('[Desktop AuthService] Dispatched jwt-available event');
|
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,37 +108,21 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const token = await invoke<string | null>('get_auth_token');
|
const token = await invoke<string | null>('get_auth_token');
|
||||||
if (token) {
|
if (token) {
|
||||||
console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
|
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Desktop AuthService] ℹ️ No token in Tauri store, checking localStorage...');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Desktop AuthService] ❌ Failed to read from Tauri store:', error);
|
console.error('[Desktop AuthService] Failed to read from Tauri store:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to localStorage
|
// Fallback to localStorage
|
||||||
const localStorageToken = localStorage.getItem('stirling_jwt');
|
return localStorage.getItem('stirling_jwt');
|
||||||
if (localStorageToken) {
|
|
||||||
console.log(`[Desktop AuthService] ✅ Token found in localStorage (length: ${localStorageToken.length})`);
|
|
||||||
} else {
|
|
||||||
console.log('[Desktop AuthService] ❌ No token found in any storage');
|
|
||||||
}
|
|
||||||
|
|
||||||
return localStorageToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get refresh token from secure storage (keyring or Tauri Store fallback)
|
* Get refresh token from secure storage (keyring or Tauri Store fallback)
|
||||||
*/
|
*/
|
||||||
private async getRefreshToken(): Promise<string | null> {
|
private async getRefreshToken(): Promise<string | null> {
|
||||||
const token = await invoke<string | null>('get_refresh_token');
|
return await invoke<string | null>('get_refresh_token');
|
||||||
if (token) {
|
|
||||||
console.log('[Desktop AuthService] ✅ Refresh token retrieved from secure storage');
|
|
||||||
} else {
|
|
||||||
console.log('[Desktop AuthService] No refresh token in secure storage');
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,19 +131,16 @@ export class AuthService {
|
|||||||
private async clearTokenEverywhere(): Promise<void> {
|
private async clearTokenEverywhere(): Promise<void> {
|
||||||
// Invalidate cache
|
// Invalidate cache
|
||||||
this.cachedToken = null;
|
this.cachedToken = null;
|
||||||
console.log('[Desktop AuthService] Cache invalidated');
|
|
||||||
|
|
||||||
// Best effort: clear Tauri keyring (both access and refresh tokens)
|
// Best effort: clear Tauri keyring (both access and refresh tokens)
|
||||||
try {
|
try {
|
||||||
await invoke('clear_auth_token');
|
await invoke('clear_auth_token');
|
||||||
console.log('[Desktop AuthService] Cleared Tauri keyring access token');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Desktop AuthService] Failed to clear Tauri keyring access token', error);
|
console.warn('[Desktop AuthService] Failed to clear Tauri keyring access token', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke('clear_refresh_token');
|
await invoke('clear_refresh_token');
|
||||||
console.log('[Desktop AuthService] Cleared Tauri keyring refresh token');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Desktop AuthService] Failed to clear Tauri keyring refresh token', error);
|
console.warn('[Desktop AuthService] Failed to clear Tauri keyring refresh token', error);
|
||||||
}
|
}
|
||||||
@@ -168,7 +149,6 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
localStorage.removeItem('stirling_jwt');
|
localStorage.removeItem('stirling_jwt');
|
||||||
localStorage.removeItem('stirling_refresh_token');
|
localStorage.removeItem('stirling_refresh_token');
|
||||||
console.log('[Desktop AuthService] Cleared localStorage tokens');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Desktop AuthService] Failed to clear localStorage tokens', error);
|
console.warn('[Desktop AuthService] Failed to clear localStorage tokens', error);
|
||||||
}
|
}
|
||||||
@@ -268,24 +248,17 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise<UserInfo> {
|
async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise<UserInfo> {
|
||||||
console.log(`[Desktop AuthService] 🔐 Starting login to: ${serverUrl}`);
|
|
||||||
console.log(`[Desktop AuthService] Username: ${username}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate SaaS configuration if connecting to SaaS
|
// Validate SaaS configuration if connecting to SaaS
|
||||||
if (serverUrl === STIRLING_SAAS_URL) {
|
if (serverUrl === STIRLING_SAAS_URL) {
|
||||||
if (!STIRLING_SAAS_URL) {
|
if (!STIRLING_SAAS_URL) {
|
||||||
console.error('[Desktop AuthService] ❌ VITE_SAAS_SERVER_URL is not configured');
|
|
||||||
throw new Error('VITE_SAAS_SERVER_URL is not configured');
|
throw new Error('VITE_SAAS_SERVER_URL is not configured');
|
||||||
}
|
}
|
||||||
if (!SUPABASE_KEY) {
|
if (!SUPABASE_KEY) {
|
||||||
console.error('[Desktop AuthService] ❌ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
|
|
||||||
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
|
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Desktop AuthService] Invoking Rust login command...');
|
|
||||||
|
|
||||||
// Call Rust login command (bypasses CORS)
|
// Call Rust login command (bypasses CORS)
|
||||||
const response = await invoke<LoginResponse>('login', {
|
const response = await invoke<LoginResponse>('login', {
|
||||||
serverUrl,
|
serverUrl,
|
||||||
@@ -298,26 +271,19 @@ export class AuthService {
|
|||||||
|
|
||||||
const { token, username: returnedUsername, email } = response;
|
const { token, username: returnedUsername, email } = response;
|
||||||
|
|
||||||
console.log('[Desktop AuthService] ✅ Login response received');
|
|
||||||
console.log(`[Desktop AuthService] Username from response: ${returnedUsername || username}`);
|
|
||||||
|
|
||||||
// Save token to all storage locations
|
// Save token to all storage locations
|
||||||
try {
|
try {
|
||||||
console.log('[Desktop AuthService] Saving token to storage...');
|
|
||||||
await this.saveTokenEverywhere(token);
|
await this.saveTokenEverywhere(token);
|
||||||
console.log('[Desktop AuthService] ✅ Token saved successfully');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Desktop AuthService] ❌ Failed to save token:', error);
|
console.error('[Desktop AuthService] Failed to save token:', error);
|
||||||
throw new Error('Failed to save authentication token');
|
throw new Error('Failed to save authentication token');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save user info to store
|
// Save user info to store
|
||||||
console.log('[Desktop AuthService] Saving user info...');
|
|
||||||
await invoke('save_user_info', {
|
await invoke('save_user_info', {
|
||||||
username: returnedUsername || username,
|
username: returnedUsername || username,
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
console.log('[Desktop AuthService] ✅ User info saved');
|
|
||||||
|
|
||||||
const userInfo: UserInfo = {
|
const userInfo: UserInfo = {
|
||||||
username: returnedUsername || username,
|
username: returnedUsername || username,
|
||||||
@@ -326,10 +292,9 @@ export class AuthService {
|
|||||||
|
|
||||||
this.setAuthStatus('authenticated', userInfo);
|
this.setAuthStatus('authenticated', userInfo);
|
||||||
|
|
||||||
console.log('[Desktop AuthService] ✅ Login completed successfully');
|
|
||||||
return userInfo;
|
return userInfo;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Desktop AuthService] ❌ Login failed:', error);
|
console.error('[Desktop AuthService] Login failed:', error);
|
||||||
|
|
||||||
// Provide more detailed error messages based on the error type
|
// Provide more detailed error messages based on the error type
|
||||||
if (error instanceof Error || typeof error === 'string') {
|
if (error instanceof Error || typeof error === 'string') {
|
||||||
@@ -338,55 +303,46 @@ export class AuthService {
|
|||||||
|
|
||||||
if (errMsg.includes('mfa_required')) {
|
if (errMsg.includes('mfa_required')) {
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
console.error('[Desktop AuthService] Two-factor authentication required');
|
|
||||||
throw new AuthServiceError('Two-factor code required.', 'mfa_required');
|
throw new AuthServiceError('Two-factor code required.', 'mfa_required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errMsg.includes('invalid_mfa_code')) {
|
if (errMsg.includes('invalid_mfa_code')) {
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
console.error('[Desktop AuthService] Invalid two-factor code provided');
|
|
||||||
throw new AuthServiceError('Invalid two-factor code.', 'invalid_mfa_code');
|
throw new AuthServiceError('Invalid two-factor code.', 'invalid_mfa_code');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentication errors
|
// Authentication errors
|
||||||
if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) {
|
if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) {
|
||||||
console.error('[Desktop AuthService] Authentication failed - invalid credentials');
|
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
throw new Error('Invalid username or password. Please check your credentials and try again.');
|
throw new Error('Invalid username or password. Please check your credentials and try again.');
|
||||||
}
|
}
|
||||||
// Server not found or unreachable
|
// Server not found or unreachable
|
||||||
else if (errMsg.includes('connection refused') || errMsg.includes('econnrefused')) {
|
else if (errMsg.includes('connection refused') || errMsg.includes('econnrefused')) {
|
||||||
console.error('[Desktop AuthService] Server connection refused');
|
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
throw new Error('Cannot connect to server. Please check the server URL and ensure the server is running.');
|
throw new Error('Cannot connect to server. Please check the server URL and ensure the server is running.');
|
||||||
}
|
}
|
||||||
// Timeout
|
// Timeout
|
||||||
else if (errMsg.includes('timeout') || errMsg.includes('timed out')) {
|
else if (errMsg.includes('timeout') || errMsg.includes('timed out')) {
|
||||||
console.error('[Desktop AuthService] Login request timed out');
|
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
throw new Error('Login request timed out. Please check your network connection and try again.');
|
throw new Error('Login request timed out. Please check your network connection and try again.');
|
||||||
}
|
}
|
||||||
// DNS failure
|
// DNS failure
|
||||||
else if (errMsg.includes('getaddrinfo') || errMsg.includes('dns') || errMsg.includes('not found') || errMsg.includes('enotfound')) {
|
else if (errMsg.includes('getaddrinfo') || errMsg.includes('dns') || errMsg.includes('not found') || errMsg.includes('enotfound')) {
|
||||||
console.error('[Desktop AuthService] DNS resolution failed');
|
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
throw new Error('Cannot resolve server address. Please check the server URL is correct.');
|
throw new Error('Cannot resolve server address. Please check the server URL is correct.');
|
||||||
}
|
}
|
||||||
// SSL/TLS errors
|
// SSL/TLS errors
|
||||||
else if (errMsg.includes('ssl') || errMsg.includes('tls') || errMsg.includes('certificate') || errMsg.includes('cert')) {
|
else if (errMsg.includes('ssl') || errMsg.includes('tls') || errMsg.includes('certificate') || errMsg.includes('cert')) {
|
||||||
console.error('[Desktop AuthService] SSL/TLS error');
|
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
throw new Error('SSL/TLS certificate error. Server may have an invalid or self-signed certificate.');
|
throw new Error('SSL/TLS certificate error. Server may have an invalid or self-signed certificate.');
|
||||||
}
|
}
|
||||||
// 404 - endpoint not found
|
// 404 - endpoint not found
|
||||||
else if (errMsg.includes('404') || errMsg.includes('not found')) {
|
else if (errMsg.includes('404') || errMsg.includes('not found')) {
|
||||||
console.error('[Desktop AuthService] Login endpoint not found');
|
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
throw new Error('Login endpoint not found. Please ensure you are connecting to a valid Stirling PDF server.');
|
throw new Error('Login endpoint not found. Please ensure you are connecting to a valid Stirling PDF server.');
|
||||||
}
|
}
|
||||||
// 403 - security disabled
|
// 403 - security disabled
|
||||||
else if (errMsg.includes('403') || errMsg.includes('forbidden')) {
|
else if (errMsg.includes('403') || errMsg.includes('forbidden')) {
|
||||||
console.error('[Desktop AuthService] Login disabled on server');
|
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
throw new Error('Login is not enabled on this server. Please enable security mode (DOCKER_ENABLE_SECURITY=true).');
|
throw new Error('Login is not enabled on this server. Please enable security mode (DOCKER_ENABLE_SECURITY=true).');
|
||||||
}
|
}
|
||||||
@@ -398,10 +354,16 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public method to save token to all storage locations
|
||||||
|
* Called by springAuthClient after token refresh to sync Tauri store
|
||||||
|
*/
|
||||||
|
async saveToken(token: string): Promise<void> {
|
||||||
|
await this.saveTokenEverywhere(token, undefined, false);
|
||||||
|
}
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log('Logging out');
|
|
||||||
|
|
||||||
// Best-effort backend logout so any server-side session/cookies are cleared
|
// Best-effort backend logout so any server-side session/cookies are cleared
|
||||||
try {
|
try {
|
||||||
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
|
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
|
||||||
@@ -444,8 +406,6 @@ export class AuthService {
|
|||||||
await invoke('clear_user_info');
|
await invoke('clear_user_info');
|
||||||
|
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
|
||||||
console.log('Logged out successfully');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during logout:', error);
|
console.error('Error during logout:', error);
|
||||||
// Still set status to unauthenticated even if clear fails
|
// Still set status to unauthenticated even if clear fails
|
||||||
@@ -457,21 +417,31 @@ export class AuthService {
|
|||||||
|
|
||||||
async getAuthToken(): Promise<string | null> {
|
async getAuthToken(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// Return cached token if available
|
// Check cached token validity before returning
|
||||||
if (this.cachedToken) {
|
if (this.cachedToken) {
|
||||||
console.debug('[Desktop AuthService] ✅ Returning cached token');
|
// Use minimal leeway (5s) for cache validation to avoid excessive invalidation
|
||||||
return this.cachedToken;
|
// Health checks run every 5s, so 30s leeway would cause 5-6 unnecessary cache clears
|
||||||
|
// The 30s leeway is used elsewhere for proactive refresh before user operations
|
||||||
|
if (this.isTokenExpiringSoon(this.cachedToken, 5)) {
|
||||||
|
console.warn('[Desktop AuthService] ⚠️ Cached token is expired or expiring soon, invalidating cache');
|
||||||
|
this.cachedToken = null;
|
||||||
|
// Fall through to fetch from storage
|
||||||
|
} else {
|
||||||
|
console.debug('[Desktop AuthService] ✅ Returning cached token');
|
||||||
|
return this.cachedToken;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug('[Desktop AuthService] Cache miss, fetching from storage...');
|
console.debug('[Desktop AuthService] Cache miss, fetching from storage...');
|
||||||
const token = await this.getTokenFromAnySource();
|
const token = await this.getTokenFromAnySource();
|
||||||
|
|
||||||
// Cache the token if valid
|
// Cache token if found (backend will validate expiry)
|
||||||
if (token && token.trim().length > 0) {
|
if (token && token.trim().length > 0) {
|
||||||
this.cachedToken = token;
|
this.cachedToken = token;
|
||||||
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
|
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
return token;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Desktop AuthService] Failed to get auth token:', error);
|
console.error('[Desktop AuthService] Failed to get auth token:', error);
|
||||||
return null;
|
return null;
|
||||||
@@ -505,6 +475,59 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async awaitRefreshIfInProgress(): Promise<boolean> {
|
||||||
|
if (!this.refreshPromise) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
console.debug('[Desktop AuthService] Waiting for in-flight refresh to complete');
|
||||||
|
return await this.refreshPromise;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Desktop AuthService] In-flight refresh failed while waiting', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isTokenExpiringSoon(token: string, leewaySeconds = 30): boolean {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length < 2) {
|
||||||
|
console.warn('[Desktop AuthService] Token malformed - less than 2 parts');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Url = parts[1];
|
||||||
|
const base64 = base64Url
|
||||||
|
.replace(/-/g, '+')
|
||||||
|
.replace(/_/g, '/')
|
||||||
|
.padEnd(Math.ceil(base64Url.length / 4) * 4, '=');
|
||||||
|
const payload = JSON.parse(atob(base64));
|
||||||
|
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
|
||||||
|
|
||||||
|
if (!expSeconds) {
|
||||||
|
console.warn('[Desktop AuthService] Token has no exp claim');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const nowWithLeeway = nowSeconds + Math.max(0, leewaySeconds);
|
||||||
|
const timeUntilExpiry = expSeconds - nowSeconds;
|
||||||
|
const isExpiring = expSeconds <= nowWithLeeway;
|
||||||
|
|
||||||
|
console.debug('[Desktop AuthService] Token expiry check:', {
|
||||||
|
expiresIn: timeUntilExpiry + 's',
|
||||||
|
leeway: leewaySeconds + 's',
|
||||||
|
isExpiring
|
||||||
|
});
|
||||||
|
|
||||||
|
return isExpiring;
|
||||||
|
} catch (err) {
|
||||||
|
// If parsing fails, treat token as unsafe/stale and force refresh path.
|
||||||
|
console.warn('[Desktop AuthService] Token parsing failed:', err);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async refreshToken(serverUrl: string): Promise<boolean> {
|
async refreshToken(serverUrl: string): Promise<boolean> {
|
||||||
// Prevent concurrent refresh attempts - reuse in-flight refresh
|
// Prevent concurrent refresh attempts - reuse in-flight refresh
|
||||||
if (this.refreshPromise) {
|
if (this.refreshPromise) {
|
||||||
@@ -542,10 +565,20 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { token } = response.data;
|
const token =
|
||||||
|
response.data?.session?.access_token ??
|
||||||
|
response.data?.access_token ??
|
||||||
|
response.data?.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.error('[Desktop AuthService] Refresh response missing token payload');
|
||||||
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
await this.logout();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Save token to all storage locations
|
// Save token to all storage locations
|
||||||
await this.saveTokenEverywhere(token);
|
await this.saveTokenEverywhere(token, undefined, false);
|
||||||
|
|
||||||
const userInfo = await this.getUserInfo();
|
const userInfo = await this.getUserInfo();
|
||||||
this.setAuthStatus('authenticated', userInfo);
|
this.setAuthStatus('authenticated', userInfo);
|
||||||
@@ -607,7 +640,7 @@ export class AuthService {
|
|||||||
const { access_token, refresh_token: newRefreshToken } = response.data;
|
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||||
|
|
||||||
// Save new tokens
|
// Save new tokens
|
||||||
await this.saveTokenEverywhere(access_token, newRefreshToken);
|
await this.saveTokenEverywhere(access_token, newRefreshToken, false);
|
||||||
|
|
||||||
const userInfo = await this.getUserInfo();
|
const userInfo = await this.getUserInfo();
|
||||||
this.setAuthStatus('authenticated', userInfo);
|
this.setAuthStatus('authenticated', userInfo);
|
||||||
@@ -630,16 +663,28 @@ export class AuthService {
|
|||||||
// If we are on the login/setup screen, don't auto-restore a previous session; clear instead
|
// If we are on the login/setup screen, don't auto-restore a previous session; clear instead
|
||||||
const path = typeof window !== 'undefined' ? window.location.pathname : '';
|
const path = typeof window !== 'undefined' ? window.location.pathname : '';
|
||||||
if (path.startsWith('/login') || path.startsWith('/setup')) {
|
if (path.startsWith('/login') || path.startsWith('/setup')) {
|
||||||
console.log('[Desktop AuthService] On login/setup path, clearing any cached auth');
|
// Check if token exists in storage (user just logged in via web flow)
|
||||||
// Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
|
const tokenInStorage = typeof window !== 'undefined' ? localStorage.getItem('stirling_jwt') : null;
|
||||||
await this.clearTokenEverywhere().catch(() => {});
|
if (tokenInStorage) {
|
||||||
try {
|
console.log('[Desktop AuthService] On login/setup path with token present - skipping validation');
|
||||||
await invoke('clear_user_info');
|
console.log('[Desktop AuthService] Login flow will handle authentication state');
|
||||||
} catch (err) {
|
// Return early to avoid clearing partial state during login completion
|
||||||
console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err);
|
// The login completion handler (completeSelfHostedSession) will:
|
||||||
|
// 1. Fetch and save user info
|
||||||
|
// 2. Set auth status to authenticated
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log('[Desktop AuthService] On login/setup path, clearing any cached auth');
|
||||||
|
// Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
|
||||||
|
await this.clearTokenEverywhere().catch(() => {});
|
||||||
|
try {
|
||||||
|
await invoke('clear_user_info');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err);
|
||||||
|
}
|
||||||
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
this.setAuthStatus('unauthenticated', null);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await this.getAuthToken();
|
const token = await this.getAuthToken();
|
||||||
|
|||||||
@@ -117,20 +117,15 @@ export class TauriBackendService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get auth token from any available source (localStorage or Tauri store)
|
* Get auth token with expiry validation
|
||||||
|
* Delegates to authService which handles caching and expiry checking
|
||||||
*/
|
*/
|
||||||
private async getAuthToken(): Promise<string | null> {
|
private async getAuthToken(): Promise<string | null> {
|
||||||
// Check localStorage first (web layer token)
|
|
||||||
const localStorageToken = localStorage.getItem('stirling_jwt');
|
|
||||||
if (localStorageToken) {
|
|
||||||
return localStorageToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to Tauri store
|
|
||||||
try {
|
try {
|
||||||
return await invoke<string | null>('get_auth_token');
|
const { authService } = await import('./authService');
|
||||||
} catch {
|
return await authService.getAuthToken();
|
||||||
console.debug('[TauriBackendService] No auth token available');
|
} catch (error) {
|
||||||
|
console.debug('[TauriBackendService] Failed to get auth token:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ describe('SpringAuthClient', () => {
|
|||||||
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
|
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
|
||||||
headers: { Authorization: `Bearer ${mockToken}` },
|
headers: { Authorization: `Bearer ${mockToken}` },
|
||||||
suppressErrorToast: true,
|
suppressErrorToast: true,
|
||||||
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
expect(result.data.session).toBeTruthy();
|
expect(result.data.session).toBeTruthy();
|
||||||
expect(result.data.session?.user).toEqual(mockUser);
|
expect(result.data.session?.user).toEqual(mockUser);
|
||||||
@@ -309,14 +310,10 @@ describe('SpringAuthClient', () => {
|
|||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
|
||||||
|
|
||||||
const result = await springAuth.refreshSession();
|
const result = await springAuth.refreshSession();
|
||||||
|
|
||||||
expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
|
expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
|
||||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
// Note: refreshSession does not dispatch jwt-available event, only notifies listeners
|
||||||
expect.objectContaining({ type: 'jwt-available' })
|
|
||||||
);
|
|
||||||
expect(result.data.session?.access_token).toBe(newToken);
|
expect(result.data.session?.access_token).toBe(newToken);
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,8 +13,29 @@ import { BASE_PATH } from '@app/constants/app';
|
|||||||
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
||||||
import { resetOAuthState } from '@app/auth/oauthStorage';
|
import { resetOAuthState } from '@app/auth/oauthStorage';
|
||||||
import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup';
|
import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup';
|
||||||
|
import {
|
||||||
|
getPlatformSessionUser,
|
||||||
|
isDesktopSaaSAuthMode,
|
||||||
|
refreshPlatformSession,
|
||||||
|
savePlatformToken,
|
||||||
|
} from '@app/extensions/platformSessionBridge';
|
||||||
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
|
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
|
||||||
|
|
||||||
|
function getHttpStatus(error: unknown): number | undefined {
|
||||||
|
if (error instanceof AxiosError) {
|
||||||
|
return error.response?.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error === 'object' && 'response' in error) {
|
||||||
|
const response = (error as { response?: { status?: unknown } }).response;
|
||||||
|
if (response && typeof response.status === 'number') {
|
||||||
|
return response.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to extract error message from axios error
|
// Helper to extract error message from axios error
|
||||||
function getErrorMessage(error: unknown, fallback: string): string {
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
if (error instanceof AxiosError) {
|
if (error instanceof AxiosError) {
|
||||||
@@ -98,14 +119,100 @@ type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => v
|
|||||||
class SpringAuthClient {
|
class SpringAuthClient {
|
||||||
private listeners: AuthChangeCallback[] = [];
|
private listeners: AuthChangeCallback[] = [];
|
||||||
private sessionCheckInterval: NodeJS.Timeout | null = null;
|
private sessionCheckInterval: NodeJS.Timeout | null = null;
|
||||||
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
|
|
||||||
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
|
// Adaptive intervals - calculated based on actual JWT token lifetime
|
||||||
|
// Defaults for initial startup (will be recalculated on first token)
|
||||||
|
private sessionCheckIntervalMs = 10000; // 10 seconds default
|
||||||
|
private tokenRefreshThresholdMs = 30000; // 30 seconds default
|
||||||
|
|
||||||
|
private readonly DESKTOP_SAAS_REFRESH_EARLY_SECONDS = 60;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Start periodic session validation
|
// Start periodic session validation
|
||||||
this.startSessionMonitoring();
|
this.startSessionMonitoring();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate optimal check interval and refresh threshold based on token lifetime.
|
||||||
|
* - Check interval: token lifetime / 6 (check 6 times during token life)
|
||||||
|
* - Refresh threshold: token lifetime / 4 (refresh when 25% remaining)
|
||||||
|
* - Applies min/max bounds for sanity
|
||||||
|
*/
|
||||||
|
private calculateAdaptiveIntervals(token: string): void {
|
||||||
|
try {
|
||||||
|
const payload = this.decodeJwtPayload(token);
|
||||||
|
if (!payload) {
|
||||||
|
console.warn('[SpringAuth] Cannot decode token for adaptive intervals, using defaults');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
|
||||||
|
const iatSeconds = typeof payload?.iat === 'number' ? payload.iat : 0;
|
||||||
|
|
||||||
|
if (expSeconds <= 0 || iatSeconds <= 0) {
|
||||||
|
console.warn('[SpringAuth] Token missing exp/iat claims, using default intervals');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenLifetimeMs = (expSeconds - iatSeconds) * 1000;
|
||||||
|
|
||||||
|
// Check interval: check 6 times during token lifetime
|
||||||
|
// Min: 5 seconds (for very short tokens)
|
||||||
|
// Max: 60 seconds (don't check too infrequently)
|
||||||
|
this.sessionCheckIntervalMs = Math.max(5000, Math.min(60000, tokenLifetimeMs / 6));
|
||||||
|
|
||||||
|
// Refresh threshold: refresh when 25% of lifetime remaining
|
||||||
|
// Min: 30 seconds (give buffer for refresh to complete)
|
||||||
|
// Max: 5 minutes (don't wait too long for long-lived tokens)
|
||||||
|
this.tokenRefreshThresholdMs = Math.max(30000, Math.min(300000, tokenLifetimeMs / 4));
|
||||||
|
|
||||||
|
console.log('[SpringAuth] 📊 Adaptive intervals calculated:', {
|
||||||
|
tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + 's',
|
||||||
|
checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + 's',
|
||||||
|
refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + 's',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart monitoring with new interval
|
||||||
|
this.restartSessionMonitoring();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SpringAuth] Failed to calculate adaptive intervals:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Url = parts[1];
|
||||||
|
const base64 = base64Url
|
||||||
|
.replace(/-/g, '+')
|
||||||
|
.replace(/_/g, '/')
|
||||||
|
.padEnd(Math.ceil(base64Url.length / 4) * 4, '=');
|
||||||
|
|
||||||
|
return JSON.parse(atob(base64));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTokenExpiry(token: string): { expiresIn: number; expiresAt: number } {
|
||||||
|
try {
|
||||||
|
const payload = this.decodeJwtPayload(token);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('Token payload missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
|
||||||
|
const expiresAt = expSeconds > 0 ? expSeconds * 1000 : Date.now() + 3600 * 1000;
|
||||||
|
const expiresIn = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
|
||||||
|
|
||||||
|
return { expiresIn, expiresAt };
|
||||||
|
} catch {
|
||||||
|
// Fallback for non-JWT or malformed tokens.
|
||||||
|
const expiresAt = Date.now() + 3600 * 1000;
|
||||||
|
return { expiresIn: 3600, expiresAt };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to get CSRF token from cookie
|
* Helper to get CSRF token from cookie
|
||||||
*/
|
*/
|
||||||
@@ -127,13 +234,54 @@ class SpringAuthClient {
|
|||||||
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
||||||
try {
|
try {
|
||||||
// Get JWT from localStorage
|
// Get JWT from localStorage
|
||||||
const token = localStorage.getItem('stirling_jwt');
|
let token = localStorage.getItem('stirling_jwt');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
|
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
|
||||||
return { data: { session: null }, error: null };
|
return { data: { session: null }, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await isDesktopSaaSAuthMode()) {
|
||||||
|
let tokenExpiry = this.getTokenExpiry(token);
|
||||||
|
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
|
||||||
|
const refreshed = await refreshPlatformSession();
|
||||||
|
if (!refreshed) {
|
||||||
|
localStorage.removeItem('stirling_jwt');
|
||||||
|
return { data: { session: null }, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedToken = localStorage.getItem('stirling_jwt');
|
||||||
|
if (!refreshedToken) {
|
||||||
|
localStorage.removeItem('stirling_jwt');
|
||||||
|
return { data: { session: null }, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
token = refreshedToken;
|
||||||
|
tokenExpiry = this.getTokenExpiry(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenExpiry.expiresIn <= 0) {
|
||||||
|
localStorage.removeItem('stirling_jwt');
|
||||||
|
return { data: { session: null }, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformUser = await getPlatformSessionUser();
|
||||||
|
|
||||||
|
const session: Session = {
|
||||||
|
user: {
|
||||||
|
id: platformUser?.email || platformUser?.username || 'desktop-saas-user',
|
||||||
|
email: platformUser?.email || '',
|
||||||
|
username: platformUser?.username || platformUser?.email || 'User',
|
||||||
|
role: 'USER',
|
||||||
|
},
|
||||||
|
access_token: token,
|
||||||
|
expires_in: tokenExpiry.expiresIn,
|
||||||
|
expires_at: tokenExpiry.expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { data: { session }, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Verify with backend
|
// Verify with backend
|
||||||
// Note: We pass the token explicitly here, overriding the interceptor's default
|
// Note: We pass the token explicitly here, overriding the interceptor's default
|
||||||
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
|
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
|
||||||
@@ -142,6 +290,8 @@ class SpringAuthClient {
|
|||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||||
|
// Session bootstrap should not trigger global 401 refresh/redirect loops.
|
||||||
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.debug('[SpringAuth] /me response status:', response.status);
|
// console.debug('[SpringAuth] /me response status:', response.status);
|
||||||
@@ -149,11 +299,12 @@ class SpringAuthClient {
|
|||||||
// console.debug('[SpringAuth] /me response data:', data);
|
// console.debug('[SpringAuth] /me response data:', data);
|
||||||
|
|
||||||
// Create session object
|
// Create session object
|
||||||
|
const tokenExpiry = this.getTokenExpiry(token);
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
user: data.user,
|
user: data.user,
|
||||||
access_token: token,
|
access_token: token,
|
||||||
expires_in: 3600,
|
expires_in: tokenExpiry.expiresIn,
|
||||||
expires_at: Date.now() + 3600 * 1000,
|
expires_at: tokenExpiry.expiresAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
||||||
@@ -161,8 +312,15 @@ class SpringAuthClient {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('[SpringAuth] getSession error:', error);
|
console.error('[SpringAuth] getSession error:', error);
|
||||||
|
|
||||||
// If 401/403, token is invalid - clear it
|
// If 401/403, token is invalid - try explicit refresh
|
||||||
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
|
const status = getHttpStatus(error);
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
// A 401 during startup can be a race with a concurrent refresh. Try one
|
||||||
|
// explicit refresh before treating the session as invalid.
|
||||||
|
const refreshResult = await this.refreshSession();
|
||||||
|
if (!refreshResult.error && refreshResult.data.session) {
|
||||||
|
return refreshResult;
|
||||||
|
}
|
||||||
localStorage.removeItem('stirling_jwt');
|
localStorage.removeItem('stirling_jwt');
|
||||||
console.debug('[SpringAuth] getSession: Not authenticated');
|
console.debug('[SpringAuth] getSession: Not authenticated');
|
||||||
return { data: { session: null }, error: null };
|
return { data: { session: null }, error: null };
|
||||||
@@ -201,6 +359,12 @@ class SpringAuthClient {
|
|||||||
localStorage.setItem('stirling_jwt', token);
|
localStorage.setItem('stirling_jwt', token);
|
||||||
// console.log('[SpringAuth] JWT stored in localStorage');
|
// console.log('[SpringAuth] JWT stored in localStorage');
|
||||||
|
|
||||||
|
// Sync token to platform-specific storage (Tauri store for desktop)
|
||||||
|
await savePlatformToken(token);
|
||||||
|
|
||||||
|
// Calculate adaptive monitoring intervals based on token lifetime
|
||||||
|
this.calculateAdaptiveIntervals(token);
|
||||||
|
|
||||||
// Dispatch custom event for other components to react to JWT availability
|
// Dispatch custom event for other components to react to JWT availability
|
||||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||||
|
|
||||||
@@ -382,6 +546,34 @@ class SpringAuthClient {
|
|||||||
*/
|
*/
|
||||||
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
||||||
try {
|
try {
|
||||||
|
if (await isDesktopSaaSAuthMode()) {
|
||||||
|
const refreshed = await refreshPlatformSession();
|
||||||
|
if (!refreshed) {
|
||||||
|
localStorage.removeItem('stirling_jwt');
|
||||||
|
return {
|
||||||
|
data: { session: null },
|
||||||
|
error: { message: 'Token refresh failed - please log in again' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await this.getSession();
|
||||||
|
if (error || !data.session) {
|
||||||
|
return {
|
||||||
|
data: { session: null },
|
||||||
|
error: error || { message: 'Token refresh failed - please log in again' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate adaptive intervals for desktop SaaS mode
|
||||||
|
const token = localStorage.getItem('stirling_jwt');
|
||||||
|
if (token) {
|
||||||
|
this.calculateAdaptiveIntervals(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifyListeners('TOKEN_REFRESHED', data.session);
|
||||||
|
return { data, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
const response = await apiClient.post('/api/v1/auth/refresh', null, {
|
const response = await apiClient.post('/api/v1/auth/refresh', null, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
||||||
@@ -396,8 +588,11 @@ class SpringAuthClient {
|
|||||||
// Update local storage with new token
|
// Update local storage with new token
|
||||||
localStorage.setItem('stirling_jwt', token);
|
localStorage.setItem('stirling_jwt', token);
|
||||||
|
|
||||||
// Dispatch custom event for other components to react to JWT availability
|
// Sync token to platform-specific storage (Tauri store for desktop)
|
||||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
await savePlatformToken(token);
|
||||||
|
|
||||||
|
// Calculate adaptive monitoring intervals based on token lifetime
|
||||||
|
this.calculateAdaptiveIntervals(token);
|
||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
user: data.user,
|
user: data.user,
|
||||||
@@ -417,7 +612,8 @@ class SpringAuthClient {
|
|||||||
localStorage.removeItem('stirling_jwt');
|
localStorage.removeItem('stirling_jwt');
|
||||||
|
|
||||||
// Handle different error statuses
|
// Handle different error statuses
|
||||||
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
|
const status = getHttpStatus(error);
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
|
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,27 +658,36 @@ class SpringAuthClient {
|
|||||||
|
|
||||||
private startSessionMonitoring() {
|
private startSessionMonitoring() {
|
||||||
// Periodically check session validity
|
// Periodically check session validity
|
||||||
// Since we use HttpOnly cookies, we just need to check with the server
|
// Interval is adaptive based on token lifetime (calculated when token is received)
|
||||||
this.sessionCheckInterval = setInterval(async () => {
|
this.sessionCheckInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
// Try to get current session
|
// Try to get current session
|
||||||
const { data } = await this.getSession();
|
const { data } = await this.getSession();
|
||||||
|
|
||||||
// If we have a session, proactively refresh if needed
|
// If we have a session, proactively refresh if needed
|
||||||
// (The server will handle token expiry, but we can be proactive)
|
|
||||||
if (data.session) {
|
if (data.session) {
|
||||||
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
|
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
|
||||||
|
|
||||||
// Refresh if token expires soon
|
// Refresh if token expires soon (threshold is adaptive)
|
||||||
if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) {
|
if (timeUntilExpiry > 0 && timeUntilExpiry < this.tokenRefreshThresholdMs) {
|
||||||
// console.log('[SpringAuth] Proactively refreshing token');
|
console.log('[SpringAuth] 🔄 Proactively refreshing token (expires in ' + Math.floor(timeUntilExpiry / 1000) + 's)');
|
||||||
await this.refreshSession();
|
await this.refreshSession();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SpringAuth] Session monitoring error:', error);
|
console.error('[SpringAuth] Session monitoring error:', error);
|
||||||
}
|
}
|
||||||
}, this.SESSION_CHECK_INTERVAL);
|
}, this.sessionCheckIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private restartSessionMonitoring() {
|
||||||
|
// Stop existing interval
|
||||||
|
if (this.sessionCheckInterval) {
|
||||||
|
clearInterval(this.sessionCheckInterval);
|
||||||
|
this.sessionCheckInterval = null;
|
||||||
|
}
|
||||||
|
// Start with new interval
|
||||||
|
this.startSessionMonitoring();
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
|
|||||||
+70
-9
@@ -21,7 +21,10 @@ interface SecuritySettingsData {
|
|||||||
persistence?: boolean;
|
persistence?: boolean;
|
||||||
enableKeyRotation?: boolean;
|
enableKeyRotation?: boolean;
|
||||||
enableKeyCleanup?: boolean;
|
enableKeyCleanup?: boolean;
|
||||||
keyRetentionDays?: number;
|
tokenExpiryMinutes?: number;
|
||||||
|
desktopTokenExpiryMinutes?: number;
|
||||||
|
allowedClockSkewSeconds?: number;
|
||||||
|
refreshGraceMinutes?: number;
|
||||||
secureCookie?: boolean;
|
secureCookie?: boolean;
|
||||||
};
|
};
|
||||||
audit?: {
|
audit?: {
|
||||||
@@ -131,7 +134,10 @@ export default function AdminSecuritySection() {
|
|||||||
'security.jwt.persistence': securitySettings.jwt?.persistence,
|
'security.jwt.persistence': securitySettings.jwt?.persistence,
|
||||||
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
|
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
|
||||||
'security.jwt.enableKeyCleanup': securitySettings.jwt?.enableKeyCleanup,
|
'security.jwt.enableKeyCleanup': securitySettings.jwt?.enableKeyCleanup,
|
||||||
'security.jwt.keyRetentionDays': securitySettings.jwt?.keyRetentionDays,
|
'security.jwt.tokenExpiryMinutes': securitySettings.jwt?.tokenExpiryMinutes,
|
||||||
|
'security.jwt.desktopTokenExpiryMinutes': securitySettings.jwt?.desktopTokenExpiryMinutes,
|
||||||
|
'security.jwt.allowedClockSkewSeconds': securitySettings.jwt?.allowedClockSkewSeconds,
|
||||||
|
'security.jwt.refreshGraceMinutes': securitySettings.jwt?.refreshGraceMinutes,
|
||||||
'security.jwt.secureCookie': securitySettings.jwt?.secureCookie,
|
'security.jwt.secureCookie': securitySettings.jwt?.secureCookie,
|
||||||
// Premium audit settings
|
// Premium audit settings
|
||||||
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
|
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
|
||||||
@@ -382,20 +388,75 @@ export default function AdminSecuritySection() {
|
|||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
name="jwt_keyRetentionDays"
|
name="jwt_tokenExpiryMinutes"
|
||||||
label={
|
label={
|
||||||
<Group component="span" gap="xs">
|
<Group component="span" gap="xs">
|
||||||
<span>{t('admin.settings.security.jwt.keyRetentionDays.label', 'Key Retention Days')}</span>
|
<span>{t('admin.settings.security.jwt.tokenExpiryMinutes.label', 'Web Token Expiry (minutes)')}</span>
|
||||||
<PendingBadge show={isFieldPending('jwt.keyRetentionDays')} />
|
<PendingBadge show={isFieldPending('jwt.tokenExpiryMinutes')} />
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
|
description={t('admin.settings.security.jwt.tokenExpiryMinutes.description', 'Access token lifetime in minutes for web clients (default: 1440 = 24 hours)')}
|
||||||
value={settings?.jwt?.keyRetentionDays || 7}
|
value={settings?.jwt?.tokenExpiryMinutes || 1440}
|
||||||
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, keyRetentionDays: Number(value) } })}
|
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, tokenExpiryMinutes: Number(value) } })}
|
||||||
min={1}
|
min={1}
|
||||||
max={365}
|
max={43200}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NumberInput
|
||||||
|
name="jwt_desktopTokenExpiryMinutes"
|
||||||
|
label={
|
||||||
|
<Group component="span" gap="xs">
|
||||||
|
<span>{t('admin.settings.security.jwt.desktopTokenExpiryMinutes.label', 'Desktop Token Expiry (minutes)')}</span>
|
||||||
|
<PendingBadge show={isFieldPending('jwt.desktopTokenExpiryMinutes')} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t('admin.settings.security.jwt.desktopTokenExpiryMinutes.description', 'Access token lifetime in minutes for desktop clients. Desktop apps automatically detected via User-Agent and receive longer sessions for better UX (default: 43200 = 30 days)')}
|
||||||
|
value={settings?.jwt?.desktopTokenExpiryMinutes || 43200}
|
||||||
|
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, desktopTokenExpiryMinutes: Number(value) } })}
|
||||||
|
min={1}
|
||||||
|
max={525600}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NumberInput
|
||||||
|
name="jwt_allowedClockSkewSeconds"
|
||||||
|
label={
|
||||||
|
<Group component="span" gap="xs">
|
||||||
|
<span>{t('admin.settings.security.jwt.allowedClockSkewSeconds.label', 'Clock Skew Tolerance (seconds)')}</span>
|
||||||
|
<PendingBadge show={isFieldPending('jwt.allowedClockSkewSeconds')} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t('admin.settings.security.jwt.allowedClockSkewSeconds.description', 'Tolerance for client/server time drift during token validation (default: 60 seconds)')}
|
||||||
|
value={settings?.jwt?.allowedClockSkewSeconds ?? 60}
|
||||||
|
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, allowedClockSkewSeconds: Number(value) } })}
|
||||||
|
min={0}
|
||||||
|
max={300}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NumberInput
|
||||||
|
name="jwt_refreshGraceMinutes"
|
||||||
|
label={
|
||||||
|
<Group component="span" gap="xs">
|
||||||
|
<span>{t('admin.settings.security.jwt.refreshGraceMinutes.label', 'Refresh Grace Period (minutes)')}</span>
|
||||||
|
<PendingBadge show={isFieldPending('jwt.refreshGraceMinutes')} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t('admin.settings.security.jwt.refreshGraceMinutes.description', 'Allow token refresh within this many minutes after expiry (default: 15 minutes, max 3 attempts)')}
|
||||||
|
value={settings?.jwt?.refreshGraceMinutes ?? 15}
|
||||||
|
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, refreshGraceMinutes: Number(value) } })}
|
||||||
|
min={0}
|
||||||
|
max={120}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export interface PlatformSessionUser {
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proprietary/web default: no desktop SaaS auth bridge.
|
||||||
|
*/
|
||||||
|
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proprietary/web default: no platform user store.
|
||||||
|
*/
|
||||||
|
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proprietary/web default: no platform refresh path.
|
||||||
|
*/
|
||||||
|
export async function refreshPlatformSession(): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proprietary/web default: no platform-specific token storage (uses localStorage only).
|
||||||
|
*/
|
||||||
|
export async function savePlatformToken(_token: string): Promise<void> {
|
||||||
|
// Web mode: token already saved to localStorage in springAuthClient
|
||||||
|
// No additional platform storage needed
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { AxiosInstance } from 'axios';
|
import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: Array<{
|
||||||
|
resolve: (token: string) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
function getJwtTokenFromStorage(): string | null {
|
function getJwtTokenFromStorage(): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -9,6 +15,24 @@ function getJwtTokenFromStorage(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setJwtTokenInStorage(token: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('stirling_jwt', token);
|
||||||
|
console.debug('[API Client] Stored new JWT token in localStorage');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Client] Failed to store JWT in localStorage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearJwtTokenFromStorage(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('stirling_jwt');
|
||||||
|
console.debug('[API Client] Cleared JWT token from localStorage');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Client] Failed to clear JWT from localStorage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getXsrfToken(): string | null {
|
function getXsrfToken(): string | null {
|
||||||
try {
|
try {
|
||||||
const cookies = document.cookie.split(';');
|
const cookies = document.cookie.split(';');
|
||||||
@@ -25,6 +49,48 @@ function getXsrfToken(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function processQueue(error: Error | null, token: string | null = null): void {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) {
|
||||||
|
prom.reject(error);
|
||||||
|
} else if (token) {
|
||||||
|
prom.resolve(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuthToken(client: AxiosInstance): Promise<string> {
|
||||||
|
console.log('[API Client] Refreshing expired JWT token...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.post('/api/v1/auth/refresh', {}, {
|
||||||
|
// Don't retry refresh requests to avoid infinite loops
|
||||||
|
headers: { 'X-Skip-Auth-Refresh': 'true' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const newToken = response.data?.session?.access_token;
|
||||||
|
if (!newToken) {
|
||||||
|
throw new Error('No access token in refresh response');
|
||||||
|
}
|
||||||
|
|
||||||
|
setJwtTokenInStorage(newToken);
|
||||||
|
console.log('[API Client] ✅ Token refreshed successfully');
|
||||||
|
return newToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Client] ❌ Token refresh failed:', error);
|
||||||
|
clearJwtTokenFromStorage();
|
||||||
|
|
||||||
|
// Redirect to login
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
console.log('[API Client] Redirecting to login page...');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||||
// Install request interceptor to add JWT token
|
// Install request interceptor to add JWT token
|
||||||
client.interceptors.request.use(
|
client.interceptors.request.use(
|
||||||
@@ -47,4 +113,61 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Install response interceptor to handle 401 and auto-refresh token
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
// Skip refresh for auth endpoints or if explicitly disabled
|
||||||
|
// Exception: /auth/me should trigger refresh (used by getSession)
|
||||||
|
if (
|
||||||
|
!originalRequest ||
|
||||||
|
(originalRequest.url?.includes('/api/v1/auth/') && !originalRequest.url?.includes('/api/v1/auth/me')) ||
|
||||||
|
originalRequest.headers?.['X-Skip-Auth-Refresh'] ||
|
||||||
|
originalRequest._retry
|
||||||
|
) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 401 errors by attempting token refresh
|
||||||
|
if (error.response?.status === 401 && getJwtTokenFromStorage()) {
|
||||||
|
console.warn('[API Client] Received 401 error, attempting token refresh...');
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
// Already refreshing - queue this request
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return client(originalRequest);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newToken = await refreshAuthToken(client);
|
||||||
|
processQueue(null, newToken);
|
||||||
|
|
||||||
|
// Retry original request with new token
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return client(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError as Error, null);
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
|||||||
|
|
||||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||||
enableAnalytics: true,
|
enableAnalytics: true,
|
||||||
appVersion: '2.4.6',
|
appVersion: '2.5.0',
|
||||||
serverCertificateEnabled: false,
|
serverCertificateEnabled: false,
|
||||||
enableAlphaFunctionality: false,
|
enableAlphaFunctionality: false,
|
||||||
enableDesktopInstallSlide: true,
|
enableDesktopInstallSlide: true,
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ security:
|
|||||||
persistence: true # Set to 'true' to enable JWT key store
|
persistence: true # Set to 'true' to enable JWT key store
|
||||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||||
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
|
|
||||||
validation: # PDF signature validation settings
|
validation: # PDF signature validation settings
|
||||||
trust:
|
trust:
|
||||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||||
|
|||||||
Reference in New Issue
Block a user