mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a9d57cba |
@@ -398,7 +398,10 @@ public class ApplicationProperties {
|
||||
private boolean enableKeystore = true;
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
private int keyRetentionDays = 7;
|
||||
private int keyRetentionDays = 30;
|
||||
private int tokenExpiryMinutes = 1440;
|
||||
private int allowedClockSkewSeconds = 60;
|
||||
private int refreshGraceMinutes = 15;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -64,7 +64,10 @@ security:
|
||||
persistence: true # Set to 'true' to enable JWT key store
|
||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
|
||||
keyRetentionDays: 30 # Number of days to retain old keys. The default is 30 days.
|
||||
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes (1 day).
|
||||
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
|
||||
refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry.
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
|
||||
+82
-8
@@ -34,6 +34,7 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
@@ -48,6 +49,8 @@ import stirling.software.proprietary.security.service.UserService;
|
||||
@Slf4j
|
||||
@Tag(name = "Authentication", description = "Endpoints for user authentication and registration")
|
||||
public class AuthController {
|
||||
private static final int DEFAULT_EXPIRY_MINUTES = 1440;
|
||||
private static final int DEFAULT_REFRESH_GRACE_MINUTES = 15;
|
||||
|
||||
private final UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
@@ -180,7 +183,12 @@ public class AuthController {
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
token,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds())));
|
||||
|
||||
} catch (UsernameNotFoundException e) {
|
||||
String username = request.getUsername();
|
||||
@@ -272,25 +280,46 @@ public class AuthController {
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(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"));
|
||||
}
|
||||
|
||||
Object usernameClaim = claims.get("sub");
|
||||
String username = usernameClaim != null ? usernameClaim.toString() : null;
|
||||
if (username == null || username.isBlank()) {
|
||||
log.warn("Token refresh rejected: missing subject claim");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", user.getAuthenticationType());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
Map<String, Object> newClaims = new HashMap<>();
|
||||
newClaims.put("authType", user.getAuthenticationType());
|
||||
newClaims.put("role", user.getRolesAsString());
|
||||
|
||||
String newToken = jwtService.generateToken(username, claims);
|
||||
String newToken = jwtService.generateToken(username, newClaims);
|
||||
|
||||
log.debug("Token refreshed for user: {}", username);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", newToken, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
newToken,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds())));
|
||||
|
||||
} catch (AuthenticationFailureException e) {
|
||||
log.warn("Token refresh failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
} catch (Exception e) {
|
||||
log.error("Token refresh error", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
@@ -532,6 +561,51 @@ public class AuthController {
|
||||
return userMap;
|
||||
}
|
||||
|
||||
private long getTokenExpirySeconds() {
|
||||
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
|
||||
int expiryMinutes = configuredMinutes > 0 ? configuredMinutes : DEFAULT_EXPIRY_MINUTES;
|
||||
return expiryMinutes * 60L;
|
||||
}
|
||||
|
||||
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 : DEFAULT_REFRESH_GRACE_MINUTES;
|
||||
return graceMinutes * 60_000L;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private ResponseEntity<?> ensureWebAuth(User user) {
|
||||
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
|
||||
+78
-19
@@ -5,6 +5,7 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -19,6 +20,9 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
@@ -30,6 +34,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
@@ -39,17 +44,23 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 43200000;
|
||||
private static final long ONE_MINUTE_MILLIS = 60_000L;
|
||||
private static final int DEFAULT_EXPIRY_MINUTES = 1440;
|
||||
private static final long DEFAULT_CLOCK_SKEW_SECONDS = 60L;
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
@Autowired
|
||||
public JwtService(
|
||||
@Qualifier("v2Enabled") boolean v2Enabled,
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
KeyPersistenceServiceInterface keyPersistenceService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
this.securityProperties = applicationProperties.getSecurity();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -86,7 +97,8 @@ public class JwtService implements JwtServiceInterface {
|
||||
.subject(username)
|
||||
.issuer(ISSUER)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
|
||||
.expiration(
|
||||
new Date(System.currentTimeMillis() + getExpirationMillis()))
|
||||
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
||||
|
||||
String keyId = activeKey.getKeyId();
|
||||
@@ -114,12 +126,23 @@ public class JwtService implements JwtServiceInterface {
|
||||
return extractClaim(token, Claims::getSubject);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String extractUsernameAllowExpired(String token) {
|
||||
return extractClaim(token, Claims::getSubject, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> extractClaims(String token) {
|
||||
Claims claims = extractAllClaims(token);
|
||||
return new HashMap<>(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> extractClaimsAllowExpired(String token) {
|
||||
Claims claims = extractAllClaims(token, true);
|
||||
return new HashMap<>(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTokenExpired(String token) {
|
||||
return extractExpiration(token).before(new Date());
|
||||
@@ -130,11 +153,21 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
|
||||
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||
final Claims claims = extractAllClaims(token);
|
||||
final Claims claims = extractAllClaims(token, false);
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
private <T> T extractClaim(
|
||||
String token, Function<Claims, T> claimsResolver, boolean allowExpired) {
|
||||
final Claims claims = extractAllClaims(token, allowExpired);
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
private Claims extractAllClaims(String token) {
|
||||
return extractAllClaims(token, false);
|
||||
}
|
||||
|
||||
private Claims extractAllClaims(String token, boolean allowExpired) {
|
||||
try {
|
||||
String keyId = extractKeyId(token);
|
||||
KeyPair keyPair;
|
||||
@@ -176,11 +209,12 @@ public class JwtService implements JwtServiceInterface {
|
||||
} else {
|
||||
log.debug("No key ID in token header, trying all available keys");
|
||||
// Try all available keys when no keyId is present
|
||||
return tryAllKeys(token);
|
||||
return tryAllKeys(token, allowExpired);
|
||||
}
|
||||
|
||||
return Jwts.parser()
|
||||
.verifyWith(keyPair.getPublic())
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@@ -191,7 +225,10 @@ public class JwtService implements JwtServiceInterface {
|
||||
log.warn("Invalid token: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("Invalid token", e);
|
||||
} catch (ExpiredJwtException e) {
|
||||
log.warn("The token has expired: {}", e.getMessage());
|
||||
if (allowExpired) {
|
||||
return e.getClaims();
|
||||
}
|
||||
log.debug("The token has expired: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (UnsupportedJwtException e) {
|
||||
log.warn("The token is unsupported: {}", e.getMessage());
|
||||
@@ -202,7 +239,8 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private Claims tryAllKeys(String token) throws AuthenticationFailureException {
|
||||
private Claims tryAllKeys(String token, boolean allowExpired)
|
||||
throws AuthenticationFailureException {
|
||||
// First try the active key
|
||||
try {
|
||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||
@@ -210,9 +248,15 @@ public class JwtService implements JwtServiceInterface {
|
||||
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (ExpiredJwtException e) {
|
||||
if (allowExpired) {
|
||||
return e.getClaims();
|
||||
}
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (SignatureException
|
||||
| NoSuchAlgorithmException
|
||||
| InvalidKeySpecException activeKeyException) {
|
||||
@@ -230,9 +274,15 @@ public class JwtService implements JwtServiceInterface {
|
||||
verificationKey.getVerifyingKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (ExpiredJwtException e) {
|
||||
if (allowExpired) {
|
||||
return e.getClaims();
|
||||
}
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (SignatureException
|
||||
| NoSuchAlgorithmException
|
||||
| InvalidKeySpecException e) {
|
||||
@@ -268,22 +318,31 @@ public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private String extractKeyId(String token) {
|
||||
try {
|
||||
PublicKey signingKey =
|
||||
keyPersistenceService.decodePublicKey(
|
||||
keyPersistenceService.getActiveKey().getVerifyingKey());
|
||||
String[] tokenParts = token.split("\\.");
|
||||
if (tokenParts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String keyId =
|
||||
(String)
|
||||
Jwts.parser()
|
||||
.verifyWith(signingKey)
|
||||
.build()
|
||||
.parse(token)
|
||||
.getHeader()
|
||||
.get("kid");
|
||||
return keyId;
|
||||
byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]);
|
||||
Map<String, Object> header =
|
||||
OBJECT_MAPPER.readValue(
|
||||
headerBytes, new TypeReference<Map<String, Object>>() {});
|
||||
Object keyId = header.get("kid");
|
||||
return keyId instanceof String ? (String) keyId : null;
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private long getExpirationMillis() {
|
||||
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
|
||||
int expiryMinutes = configuredMinutes > 0 ? configuredMinutes : DEFAULT_EXPIRY_MINUTES;
|
||||
return expiryMinutes * ONE_MINUTE_MILLIS;
|
||||
}
|
||||
|
||||
private long getAllowedClockSkewSeconds() {
|
||||
int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds();
|
||||
return configuredSeconds >= 0 ? configuredSeconds : DEFAULT_CLOCK_SKEW_SECONDS;
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -41,6 +41,15 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String extractUsername(String token);
|
||||
|
||||
/**
|
||||
* Extract username from JWT token while allowing expired tokens. Signature and token structure
|
||||
* must still be valid.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return username extracted from token
|
||||
*/
|
||||
String extractUsernameAllowExpired(String token);
|
||||
|
||||
/**
|
||||
* Extract all claims from JWT token
|
||||
*
|
||||
@@ -49,6 +58,15 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
Map<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
|
||||
*
|
||||
|
||||
+40
-1
@@ -10,6 +10,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -58,6 +60,8 @@ class AuthControllerLoginTest {
|
||||
void setUp() {
|
||||
securityProperties = new ApplicationProperties.Security();
|
||||
securityProperties.setLoginMethod("all");
|
||||
securityProperties.getJwt().setTokenExpiryMinutes(60);
|
||||
securityProperties.getJwt().setRefreshGraceMinutes(5);
|
||||
|
||||
AuthController controller =
|
||||
new AuthController(
|
||||
@@ -175,7 +179,10 @@ class AuthControllerLoginTest {
|
||||
void refreshReturnsNewTokenWhenValid() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "user@example.com");
|
||||
claims.put("exp", new Date(System.currentTimeMillis() + 60_000));
|
||||
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
@@ -187,6 +194,38 @@ class AuthControllerLoginTest {
|
||||
.andExpect(jsonPath("$.session.expires_in").value(3600));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRejectsTokenExpiredBeyondGrace() throws Exception {
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "user@example.com");
|
||||
claims.put("exp", new Date(System.currentTimeMillis() - (10 * 60_000)));
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshAcceptsTokenExpiredWithinGrace() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "user@example.com");
|
||||
claims.put("exp", new Date(System.currentTimeMillis() - (60_000)));
|
||||
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.session.access_token").value("new-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCurrentUserReturnsUnauthorizedWhenAnonymous() throws Exception {
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
+3
-15
@@ -31,6 +31,7 @@ import org.springframework.security.core.Authentication;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
@@ -64,7 +65,8 @@ class JwtServiceTest {
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
|
||||
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
|
||||
|
||||
jwtService = new JwtService(true, keystoreService);
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
jwtService = new JwtService(true, keystoreService, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -73,8 +75,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -94,8 +94,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -114,8 +112,6 @@ class JwtServiceTest {
|
||||
void testValidateTokenSuccess() throws Exception {
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn("testuser");
|
||||
|
||||
@@ -179,8 +175,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(user);
|
||||
when(user.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -207,8 +201,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -281,8 +273,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -307,8 +297,6 @@ class JwtServiceTest {
|
||||
// First, generate a token successfully
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 mode === 'saas';
|
||||
} 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 {
|
||||
return await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ let lastBackendToast = 0;
|
||||
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
|
||||
operationName?: string;
|
||||
skipBackendReadyCheck?: boolean;
|
||||
skipAuthRedirect?: boolean;
|
||||
_retry?: boolean;
|
||||
}
|
||||
|
||||
@@ -55,7 +56,10 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// Self-hosted mode: enable credentials for session management
|
||||
extendedConfig.withCredentials = true;
|
||||
|
||||
const token = await authService.getAuthToken();
|
||||
// If another request is already refreshing, wait before attaching token.
|
||||
await authService.awaitRefreshIfInProgress();
|
||||
let token = await authService.getAuthToken();
|
||||
|
||||
if (token) {
|
||||
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
@@ -104,9 +108,16 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config as ExtendedRequestConfig;
|
||||
const requestUrl = String(originalRequest?.url || '');
|
||||
const isAuthProbeRequest = requestUrl.includes('/api/v1/auth/me');
|
||||
|
||||
// Handle 401 Unauthorized - try to refresh token
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
// `/auth/me` is used as a probe by session bootstrap; refreshing here can
|
||||
// create recursion (refresh -> save token -> jwt-available -> /auth/me).
|
||||
if (isAuthProbeRequest) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
console.warn('[apiClientSetup] 401 on path:', window.location.pathname, 'url:', originalRequest.url);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,11 @@ export class AuthService {
|
||||
/**
|
||||
* 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
|
||||
if (!token || token.trim().length === 0) {
|
||||
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
|
||||
@@ -95,9 +99,11 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Notify other parts of the system
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
console.log('[Desktop AuthService] Dispatched jwt-available event');
|
||||
if (emitJwtAvailable) {
|
||||
// Notify other parts of the system when a brand-new auth session is established.
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
console.log('[Desktop AuthService] Dispatched jwt-available event');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -478,6 +484,46 @@ 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) {
|
||||
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) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nowWithLeeway = Math.floor(Date.now() / 1000) + Math.max(0, leewaySeconds);
|
||||
return expSeconds <= nowWithLeeway;
|
||||
} catch {
|
||||
// If parsing fails, treat token as unsafe/stale and force refresh path.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
const token = await this.getAuthToken();
|
||||
return token !== null;
|
||||
@@ -542,10 +588,20 @@ export class AuthService {
|
||||
}
|
||||
);
|
||||
|
||||
const { token } = response.data;
|
||||
const token =
|
||||
response.data?.session?.access_token ??
|
||||
response.data?.access_token ??
|
||||
response.data?.token;
|
||||
|
||||
if (!token) {
|
||||
console.error('[Desktop AuthService] Refresh response missing token payload');
|
||||
this.setAuthStatus('unauthenticated', null);
|
||||
await this.logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save token to all storage locations
|
||||
await this.saveTokenEverywhere(token);
|
||||
await this.saveTokenEverywhere(token, undefined, false);
|
||||
|
||||
const userInfo = await this.getUserInfo();
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
@@ -607,7 +663,7 @@ export class AuthService {
|
||||
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||
|
||||
// Save new tokens
|
||||
await this.saveTokenEverywhere(access_token, newRefreshToken);
|
||||
await this.saveTokenEverywhere(access_token, newRefreshToken, false);
|
||||
|
||||
const userInfo = await this.getUserInfo();
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
|
||||
import * as platformSessionBridge from '@app/extensions/platformSessionBridge';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
@@ -9,6 +10,11 @@ vi.mock('@app/services/apiClient');
|
||||
vi.mock('@app/extensions/oauthNavigation', () => ({
|
||||
startOAuthNavigation: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
vi.mock('@app/extensions/platformSessionBridge', () => ({
|
||||
isDesktopSaaSAuthMode: vi.fn().mockResolvedValue(false),
|
||||
getPlatformSessionUser: vi.fn().mockResolvedValue(null),
|
||||
refreshPlatformSession: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
describe('SpringAuthClient', () => {
|
||||
beforeEach(() => {
|
||||
@@ -32,7 +38,10 @@ describe('SpringAuthClient', () => {
|
||||
});
|
||||
|
||||
it('should validate JWT and return session when JWT exists', async () => {
|
||||
const mockToken = 'mock-jwt-token';
|
||||
const exp = Math.floor(Date.now() / 1000) + 1800;
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({ exp, marker: 'a-b_c' })).toString('base64url');
|
||||
const mockToken = `${header}.${payload}.sig`;
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
@@ -52,14 +61,121 @@ describe('SpringAuthClient', () => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${mockToken}` },
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
expect(result.data.session).toBeTruthy();
|
||||
expect(result.data.session?.user).toEqual(mockUser);
|
||||
expect(result.data.session?.access_token).toBe(mockToken);
|
||||
expect(result.data.session?.expires_at).toBe(exp * 1000);
|
||||
expect(result.data.session?.expires_in).toBeGreaterThan(0);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear invalid JWT on 401 error', async () => {
|
||||
it('should clear token and return null session for desktop SaaS when expired refresh fails', async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) - 60;
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({ exp })).toString('base64url');
|
||||
const expiredToken = `${header}.${payload}.sig`;
|
||||
localStorage.setItem('stirling_jwt', expiredToken);
|
||||
|
||||
vi.mocked(platformSessionBridge.isDesktopSaaSAuthMode).mockResolvedValueOnce(true);
|
||||
vi.mocked(platformSessionBridge.refreshPlatformSession).mockResolvedValueOnce(false);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(result.data.session).toBeNull();
|
||||
expect(result.error).toBeNull();
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh and recover session when /auth/me returns 401', async () => {
|
||||
const staleToken = 'stale-jwt-token';
|
||||
const refreshedToken = 'fresh-jwt-token';
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
role: 'USER',
|
||||
};
|
||||
localStorage.setItem('stirling_jwt', staleToken);
|
||||
|
||||
const authMe401 = new AxiosError(
|
||||
'Unauthorized',
|
||||
'ERR_BAD_REQUEST',
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
data: {},
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
}
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(authMe401);
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
user: mockUser,
|
||||
session: {
|
||||
access_token: refreshedToken,
|
||||
expires_in: 3600,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/auth/refresh',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
withCredentials: true,
|
||||
suppressErrorToast: true,
|
||||
})
|
||||
);
|
||||
expect(localStorage.getItem('stirling_jwt')).toBe(refreshedToken);
|
||||
expect(result.data.session?.access_token).toBe(refreshedToken);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should refresh and recover session when /auth/me returns axios-like 401 error object', async () => {
|
||||
const staleToken = 'stale-jwt-token';
|
||||
const refreshedToken = 'fresh-jwt-token';
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
role: 'USER',
|
||||
};
|
||||
localStorage.setItem('stirling_jwt', staleToken);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 401, data: {} },
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
user: mockUser,
|
||||
session: {
|
||||
access_token: refreshedToken,
|
||||
expires_in: 3600,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(localStorage.getItem('stirling_jwt')).toBe(refreshedToken);
|
||||
expect(result.data.session?.access_token).toBe(refreshedToken);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear invalid JWT on 401 error when refresh fails', async () => {
|
||||
const mockToken = 'invalid-jwt-token';
|
||||
localStorage.setItem('stirling_jwt', mockToken);
|
||||
|
||||
@@ -78,6 +194,11 @@ describe('SpringAuthClient', () => {
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
message: 'Token expired',
|
||||
});
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
@@ -87,7 +208,7 @@ describe('SpringAuthClient', () => {
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear invalid JWT on 403 error', async () => {
|
||||
it('should clear invalid JWT on 403 error when refresh fails', async () => {
|
||||
const mockToken = 'forbidden-jwt-token';
|
||||
localStorage.setItem('stirling_jwt', mockToken);
|
||||
|
||||
@@ -106,6 +227,11 @@ describe('SpringAuthClient', () => {
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 403 },
|
||||
message: 'Forbidden',
|
||||
});
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
@@ -309,14 +435,9 @@ describe('SpringAuthClient', () => {
|
||||
},
|
||||
} as any);
|
||||
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
const result = await springAuth.refreshSession();
|
||||
|
||||
expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'jwt-available' })
|
||||
);
|
||||
expect(result.data.session?.access_token).toBe(newToken);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
@@ -13,8 +13,28 @@ import { BASE_PATH } from '@app/constants/app';
|
||||
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
||||
import { resetOAuthState } from '@app/auth/oauthStorage';
|
||||
import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup';
|
||||
import {
|
||||
getPlatformSessionUser,
|
||||
isDesktopSaaSAuthMode,
|
||||
refreshPlatformSession,
|
||||
} from '@app/extensions/platformSessionBridge';
|
||||
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
|
||||
|
||||
function getHttpStatus(error: unknown): number | undefined {
|
||||
if (error instanceof AxiosError) {
|
||||
return error.response?.status;
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const response = (error as { response?: { status?: unknown } }).response;
|
||||
if (response && typeof response.status === 'number') {
|
||||
return response.status;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper to extract error message from axios error
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof AxiosError) {
|
||||
@@ -100,12 +120,47 @@ class SpringAuthClient {
|
||||
private sessionCheckInterval: NodeJS.Timeout | null = null;
|
||||
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
|
||||
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
|
||||
private readonly DESKTOP_SAAS_REFRESH_EARLY_SECONDS = 60;
|
||||
|
||||
constructor() {
|
||||
// Start periodic session validation
|
||||
this.startSessionMonitoring();
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -127,13 +182,54 @@ class SpringAuthClient {
|
||||
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
||||
try {
|
||||
// Get JWT from localStorage
|
||||
const token = localStorage.getItem('stirling_jwt');
|
||||
let token = localStorage.getItem('stirling_jwt');
|
||||
|
||||
if (!token) {
|
||||
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
if (await isDesktopSaaSAuthMode()) {
|
||||
let tokenExpiry = this.getTokenExpiry(token);
|
||||
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
|
||||
const refreshed = await refreshPlatformSession();
|
||||
if (!refreshed) {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
const refreshedToken = localStorage.getItem('stirling_jwt');
|
||||
if (!refreshedToken) {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
token = refreshedToken;
|
||||
tokenExpiry = this.getTokenExpiry(token);
|
||||
}
|
||||
|
||||
if (tokenExpiry.expiresIn <= 0) {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
const platformUser = await getPlatformSessionUser();
|
||||
|
||||
const session: Session = {
|
||||
user: {
|
||||
id: platformUser?.email || platformUser?.username || 'desktop-saas-user',
|
||||
email: platformUser?.email || '',
|
||||
username: platformUser?.username || platformUser?.email || 'User',
|
||||
role: 'USER',
|
||||
},
|
||||
access_token: token,
|
||||
expires_in: tokenExpiry.expiresIn,
|
||||
expires_at: tokenExpiry.expiresAt,
|
||||
};
|
||||
|
||||
return { data: { session }, error: null };
|
||||
}
|
||||
|
||||
// Verify with backend
|
||||
// Note: We pass the token explicitly here, overriding the interceptor's default
|
||||
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
|
||||
@@ -142,6 +238,8 @@ class SpringAuthClient {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||
// Session bootstrap should not trigger global 401 refresh/redirect loops.
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
|
||||
// console.debug('[SpringAuth] /me response status:', response.status);
|
||||
@@ -149,11 +247,12 @@ class SpringAuthClient {
|
||||
// console.debug('[SpringAuth] /me response data:', data);
|
||||
|
||||
// Create session object
|
||||
const tokenExpiry = this.getTokenExpiry(token);
|
||||
const session: Session = {
|
||||
user: data.user,
|
||||
access_token: token,
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600 * 1000,
|
||||
expires_in: tokenExpiry.expiresIn,
|
||||
expires_at: tokenExpiry.expiresAt,
|
||||
};
|
||||
|
||||
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
||||
@@ -162,7 +261,14 @@ class SpringAuthClient {
|
||||
console.error('[SpringAuth] getSession error:', error);
|
||||
|
||||
// If 401/403, token is invalid - clear it
|
||||
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
|
||||
const status = getHttpStatus(error);
|
||||
if (status === 401 || status === 403) {
|
||||
// A 401 during startup can be a race with a concurrent refresh. Try one
|
||||
// explicit refresh before treating the session as invalid.
|
||||
const refreshResult = await this.refreshSession();
|
||||
if (!refreshResult.error && refreshResult.data.session) {
|
||||
return refreshResult;
|
||||
}
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
console.debug('[SpringAuth] getSession: Not authenticated');
|
||||
return { data: { session: null }, error: null };
|
||||
@@ -201,9 +307,6 @@ class SpringAuthClient {
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
// console.log('[SpringAuth] JWT stored in localStorage');
|
||||
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
|
||||
const session: Session = {
|
||||
user: data.user,
|
||||
access_token: token,
|
||||
@@ -382,6 +485,28 @@ class SpringAuthClient {
|
||||
*/
|
||||
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
||||
try {
|
||||
if (await isDesktopSaaSAuthMode()) {
|
||||
const refreshed = await refreshPlatformSession();
|
||||
if (!refreshed) {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
return {
|
||||
data: { session: null },
|
||||
error: { message: 'Token refresh failed - please log in again' },
|
||||
};
|
||||
}
|
||||
|
||||
const { data, error } = await this.getSession();
|
||||
if (error || !data.session) {
|
||||
return {
|
||||
data: { session: null },
|
||||
error: error || { message: 'Token refresh failed - please log in again' },
|
||||
};
|
||||
}
|
||||
|
||||
this.notifyListeners('TOKEN_REFRESHED', data.session);
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/api/v1/auth/refresh', null, {
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
||||
@@ -417,7 +542,8 @@ class SpringAuthClient {
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
|
||||
// Handle different error statuses
|
||||
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
|
||||
const status = getHttpStatus(error);
|
||||
if (status === 401 || status === 403) {
|
||||
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user