mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
9
Commits
wt2
...
desktopFixdefault
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e7a8181a | ||
|
|
3d57510eae | ||
|
|
4b94628976 | ||
|
|
5170cd2444 | ||
|
|
59361b4231 | ||
|
|
691aaafae6 | ||
|
|
024d67b393 | ||
|
|
67ecba1519 | ||
|
|
63eceb496f |
@@ -398,7 +398,9 @@ 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;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+2
-1
@@ -426,7 +426,8 @@ public class PdfJsonFallbackFontService {
|
||||
String normalized =
|
||||
WHITESPACE_PATTERN
|
||||
.matcher(
|
||||
PATTERN.matcher(originalFontName).replaceAll("") // Remove subset prefix
|
||||
PATTERN.matcher(originalFontName)
|
||||
.replaceAll("") // Remove subset prefix
|
||||
.toLowerCase())
|
||||
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
|
||||
// "timesnewroman")
|
||||
|
||||
@@ -64,7 +64,9 @@ 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.
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
|
||||
+24
-4
@@ -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;
|
||||
@@ -180,7 +181,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,8 +278,7 @@ public class AuthController {
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(token);
|
||||
String username = jwtService.extractUsernameAllowExpired(token);
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
@@ -289,8 +294,17 @@ public class AuthController {
|
||||
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.error("Token refresh error", e);
|
||||
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 +546,12 @@ public class AuthController {
|
||||
return userMap;
|
||||
}
|
||||
|
||||
private long getTokenExpirySeconds() {
|
||||
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
|
||||
int expiryMinutes = configuredMinutes > 0 ? configuredMinutes : 720;
|
||||
return expiryMinutes * 60L;
|
||||
}
|
||||
|
||||
private ResponseEntity<?> ensureWebAuth(User user) {
|
||||
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
|
||||
+49
-4
@@ -30,6 +30,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 +40,21 @@ 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 = 720;
|
||||
|
||||
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 +91,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 +120,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 +147,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;
|
||||
@@ -181,6 +208,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
|
||||
return Jwts.parser()
|
||||
.verifyWith(keyPair.getPublic())
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@@ -191,6 +219,9 @@ public class JwtService implements JwtServiceInterface {
|
||||
log.warn("Invalid token: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("Invalid token", e);
|
||||
} catch (ExpiredJwtException e) {
|
||||
if (allowExpired) {
|
||||
return e.getClaims();
|
||||
}
|
||||
log.warn("The token has expired: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (UnsupportedJwtException e) {
|
||||
@@ -210,6 +241,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@@ -230,6 +262,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
verificationKey.getVerifyingKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@@ -276,6 +309,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
(String)
|
||||
Jwts.parser()
|
||||
.verifyWith(signingKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parse(token)
|
||||
.getHeader()
|
||||
@@ -286,4 +320,15 @@ public class JwtService implements JwtServiceInterface {
|
||||
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 : 60L;
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
*
|
||||
|
||||
+2
-1
@@ -58,6 +58,7 @@ class AuthControllerLoginTest {
|
||||
void setUp() {
|
||||
securityProperties = new ApplicationProperties.Security();
|
||||
securityProperties.setLoginMethod("all");
|
||||
securityProperties.getJwt().setTokenExpiryMinutes(60);
|
||||
|
||||
AuthController controller =
|
||||
new AuthController(
|
||||
@@ -175,7 +176,7 @@ class AuthControllerLoginTest {
|
||||
void refreshReturnsNewTokenWhenValid() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
|
||||
when(jwtService.extractUsernameAllowExpired("old")).thenReturn("user@example.com");
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.4.5'
|
||||
version = '2.5.0'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.4.6",
|
||||
"version": "2.5.0",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -13,8 +13,10 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "Stirling-PDF",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"minWidth": 1100,
|
||||
"minHeight": 700,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
export interface FooterInfo {
|
||||
analyticsEnabled?: boolean;
|
||||
@@ -29,8 +30,14 @@ export function useFooterInfo() {
|
||||
setFooterInfo(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[useFooterInfo] Failed to fetch footer info:', err);
|
||||
setError(err as Error);
|
||||
const status = err instanceof AxiosError ? err.response?.status : undefined;
|
||||
if (status !== 404) {
|
||||
console.error('[useFooterInfo] Failed to fetch footer info:', err);
|
||||
setError(err as Error);
|
||||
} else {
|
||||
// Older servers may not expose this endpoint.
|
||||
setError(null);
|
||||
}
|
||||
// Set defaults on error
|
||||
setFooterInfo({
|
||||
analyticsEnabled: false,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useLogoPath } from "@app/hooks/useLogoPath";
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import { useFileContext } from "@app/contexts/file/fileHooks";
|
||||
import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||
|
||||
@@ -55,27 +55,22 @@ export default function HomePage() {
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
|
||||
const { activeFiles } = useFileContext();
|
||||
const navigationState = useNavigationState();
|
||||
const { actions } = useNavigationActions();
|
||||
const { setActiveFileIndex } = useViewer();
|
||||
const prevFileCountRef = useRef(activeFiles.length);
|
||||
const prevFileCountRef = useRef(0);
|
||||
|
||||
// Auto-switch to viewer when going from 0 to 1 file
|
||||
// Skip this if PDF Text Editor is active - it handles its own empty state
|
||||
// Startup/open transition behavior:
|
||||
// - opening exactly 1 file from empty -> viewer
|
||||
// - opening 2+ files from empty -> fileEditor
|
||||
useEffect(() => {
|
||||
const prevCount = prevFileCountRef.current;
|
||||
const currentCount = activeFiles.length;
|
||||
|
||||
if (
|
||||
navigationState.workbench !== 'fileEditor' &&
|
||||
prevCount === 0 &&
|
||||
currentCount === 1
|
||||
) {
|
||||
// PDF Text Editor handles its own empty state with a dropzone
|
||||
if (selectedToolKey !== 'pdfTextEditor') {
|
||||
actions.setWorkbench('viewer');
|
||||
setActiveFileIndex(0);
|
||||
}
|
||||
if (prevCount === 0 && currentCount === 1) {
|
||||
actions.setWorkbench('viewer');
|
||||
setActiveFileIndex(0);
|
||||
} else if (prevCount === 0 && currentCount > 1) {
|
||||
actions.setWorkbench('fileEditor');
|
||||
}
|
||||
|
||||
prevFileCountRef.current = currentCount;
|
||||
@@ -83,8 +78,6 @@ export default function HomePage() {
|
||||
activeFiles.length,
|
||||
actions,
|
||||
setActiveFileIndex,
|
||||
selectedToolKey,
|
||||
navigationState.workbench,
|
||||
]);
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.4.6',
|
||||
appVersion: '2.5.0',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
@@ -189,4 +189,3 @@ export function getSimulatedLicenseInfo(): LicenseInfo | null {
|
||||
}
|
||||
|
||||
export const DEV_TESTING_ENABLED = DEV_TESTING_MODE;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,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);
|
||||
}
|
||||
|
||||
@@ -542,7 +542,17 @@ 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);
|
||||
|
||||
@@ -52,6 +52,7 @@ 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);
|
||||
|
||||
@@ -13,6 +13,11 @@ 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';
|
||||
|
||||
// Helper to extract error message from axios error
|
||||
@@ -106,6 +111,26 @@ class SpringAuthClient {
|
||||
this.startSessionMonitoring();
|
||||
}
|
||||
|
||||
private getTokenExpiry(token: string): { expiresIn: number; expiresAt: number } {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) {
|
||||
throw new Error('Token payload missing');
|
||||
}
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
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 +152,40 @@ 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 <= 0) {
|
||||
const refreshed = await refreshPlatformSession();
|
||||
if (refreshed) {
|
||||
token = localStorage.getItem('stirling_jwt') || token;
|
||||
tokenExpiry = this.getTokenExpiry(token);
|
||||
}
|
||||
}
|
||||
|
||||
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 +194,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 +203,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');
|
||||
@@ -382,6 +437,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() || '',
|
||||
|
||||
+1
-1
@@ -392,7 +392,7 @@ export default function AdminSecuritySection() {
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
|
||||
value={settings?.jwt?.keyRetentionDays || 7}
|
||||
value={settings?.jwt?.keyRetentionDays || 30}
|
||||
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, keyRetentionDays: Number(value) } })}
|
||||
min={1}
|
||||
max={365}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.4.6',
|
||||
appVersion: '2.5.0',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
enableDesktopInstallSlide: true,
|
||||
@@ -208,4 +208,3 @@ export function getSimulatedLicenseInfo(): LicenseInfo | null {
|
||||
}
|
||||
|
||||
export const DEV_TESTING_ENABLED = DEV_TESTING_MODE;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user