Compare commits

...
Author SHA1 Message Date
Anthony Stirling 67a9d57cba feat: desktop SaaS authentication integration
Adds platform session bridge architecture for desktop SaaS mode:

Backend (dependency):
- JWT token management improvements (from astirli-jwt-token-management)

Frontend:
- Platform session bridge with stub/shadow pattern
  - Desktop: integrates with Supabase authentication
  - Proprietary/web: stub implementation (returns false)
- Desktop SaaS mode detection and session management
- Token expiry parsing from JWT payload
- Automatic token refresh for desktop SaaS users
- Bypass /me verification in SaaS mode (trusted environment)

Changes:
- springAuthClient: add getTokenExpiry(), desktop SaaS flow
- authService: improve token refresh with Supabase integration
- platformSessionBridge: new architecture for platform-specific auth
- apiClientSetup: SaaS refresh integration

Depends-On: astirli-jwt-token-management
2026-02-16 10:40:22 +00:00
13 changed files with 628 additions and 69 deletions
@@ -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)
@@ -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)
@@ -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;
}
}
@@ -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
*
@@ -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();
@@ -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);
}
+63 -7
View File
@@ -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;
}