mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✨ feat(desktop): add proactive token refresh on app startup and activation
- Refresh token on every app launch (with 5-minute debounce to prevent rapid restarts) - Trigger refresh on Mac app activation (Dock click) with same debounce - Track lastRefreshAt timestamp in encrypted token storage - Add isRemoteServerConfigured() helper method to avoid code duplication - Fix cloud mode check that incorrectly required remoteServerUrl - Add comprehensive tests for all refresh scenarios
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AuthorizationPhase,
|
||||
AuthorizationProgress,
|
||||
DataSyncConfig,
|
||||
MarketAuthorizationParams,
|
||||
@@ -18,6 +17,7 @@ const logger = createLogger('controllers:AuthCtr');
|
||||
|
||||
const MAX_POLL_TIME = 2 * 60 * 1000; // 2 minutes (reduced from 5 minutes for better UX)
|
||||
const POLL_INTERVAL = 3000; // 3 seconds
|
||||
const TOKEN_REFRESH_DEBOUNCE = 5 * 60 * 1000; // 5 minutes - debounce interval to prevent excessive refreshes on rapid app restarts
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
@@ -289,34 +289,35 @@ export default class AuthCtr extends ControllerModule {
|
||||
this.autoRefreshTimer = setInterval(async () => {
|
||||
try {
|
||||
// Check if token is expiring soon (refresh 5 minutes in advance)
|
||||
if (this.remoteServerConfigCtr.isTokenExpiringSoon()) {
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
logger.info(
|
||||
`Token is expiring soon, triggering auto-refresh. Expires at: ${expiresAt ? new Date(expiresAt).toISOString() : 'unknown'}`,
|
||||
);
|
||||
if (!this.remoteServerConfigCtr.isTokenExpiringSoon()) {
|
||||
return;
|
||||
}
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
logger.info(
|
||||
`Token is expiring soon, triggering auto-refresh. Expires at: ${expiresAt ? new Date(expiresAt).toISOString() : 'unknown'}`,
|
||||
);
|
||||
|
||||
const result = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (result.success) {
|
||||
logger.info('Auto-refresh successful');
|
||||
this.broadcastTokenRefreshed();
|
||||
const result = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (result.success) {
|
||||
logger.info('Auto-refresh successful');
|
||||
this.broadcastTokenRefreshed();
|
||||
} else {
|
||||
logger.error(`Auto-refresh failed after retries: ${result.error}`);
|
||||
|
||||
// Only clear tokens for non-retryable errors (e.g., invalid_grant)
|
||||
// The retry mechanism in RemoteServerConfigCtr already handles transient errors
|
||||
if (this.remoteServerConfigCtr.isNonRetryableError(result.error)) {
|
||||
logger.warn(
|
||||
'Non-retryable error detected, clearing tokens and requiring re-authorization',
|
||||
);
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
logger.error(`Auto-refresh failed after retries: ${result.error}`);
|
||||
|
||||
// Only clear tokens for non-retryable errors (e.g., invalid_grant)
|
||||
// The retry mechanism in RemoteServerConfigCtr already handles transient errors
|
||||
if (this.remoteServerConfigCtr.isNonRetryableError(result.error)) {
|
||||
logger.warn(
|
||||
'Non-retryable error detected, clearing tokens and requiring re-authorization',
|
||||
);
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
// For other errors (after retries exhausted), log but don't clear tokens immediately
|
||||
// The next refresh cycle will retry
|
||||
logger.warn('Refresh failed but error may be transient, will retry on next cycle');
|
||||
}
|
||||
// For other errors (after retries exhausted), log but don't clear tokens immediately
|
||||
// The next refresh cycle will retry
|
||||
logger.warn('Refresh failed but error may be transient, will retry on next cycle');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -663,13 +664,14 @@ export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
* Initialize auto-refresh functionality
|
||||
* Checks for valid token at app startup and starts auto-refresh timer if token exists
|
||||
* Proactively refreshes token on every startup (with 5-minute debounce to prevent rapid restart issues)
|
||||
*/
|
||||
private async initializeAutoRefresh() {
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
|
||||
// Check if remote server is configured and active
|
||||
if (!config.active || !config.remoteServerUrl) {
|
||||
if (!(await this.remoteServerConfigCtr.isRemoteServerConfigured(config))) {
|
||||
logger.debug(
|
||||
'Remote server not active or configured, skipping auto-refresh initialization',
|
||||
);
|
||||
@@ -690,44 +692,121 @@ export default class AuthCtr extends ControllerModule {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token has already expired
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Check if token has already expired
|
||||
if (currentTime >= expiresAt) {
|
||||
logger.info('Token has expired, attempting to refresh it');
|
||||
await this.performProactiveRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to refresh token (includes retry mechanism)
|
||||
const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (refreshResult.success) {
|
||||
logger.info('Token refresh successful during initialization');
|
||||
this.broadcastTokenRefreshed();
|
||||
// Restart auto-refresh timer
|
||||
this.startAutoRefresh();
|
||||
return;
|
||||
} else {
|
||||
logger.error(`Token refresh failed during initialization: ${refreshResult.error}`);
|
||||
|
||||
// Only clear token for non-retryable errors
|
||||
if (this.remoteServerConfigCtr.isNonRetryableError(refreshResult.error)) {
|
||||
logger.warn('Non-retryable error during initialization, clearing tokens');
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
// For transient errors, still start auto-refresh timer to retry later
|
||||
logger.warn('Transient error during initialization, will retry via auto-refresh');
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Proactively refresh token if it hasn't been refreshed in the last 6 hours
|
||||
// This ensures token validity even if the server has revoked it
|
||||
if (this.shouldProactivelyRefresh()) {
|
||||
logger.info('Token refresh interval exceeded, proactively refreshing token on startup');
|
||||
await this.performProactiveRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start auto-refresh timer
|
||||
logger.info(
|
||||
`Token is valid, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
|
||||
`Token is valid and recently refreshed, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
|
||||
);
|
||||
this.startAutoRefresh();
|
||||
} catch (error) {
|
||||
logger.error('Error during auto-refresh initialization:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token should be proactively refreshed
|
||||
* Returns true if the token hasn't been refreshed recently (within debounce interval)
|
||||
* This ensures we refresh on every app launch while preventing excessive refreshes on rapid restarts
|
||||
*/
|
||||
private shouldProactivelyRefresh(): boolean {
|
||||
const lastRefreshAt = this.remoteServerConfigCtr.getLastTokenRefreshAt();
|
||||
|
||||
// If never refreshed, should refresh
|
||||
if (!lastRefreshAt) {
|
||||
logger.debug('No last refresh time found, should proactively refresh');
|
||||
return true;
|
||||
}
|
||||
|
||||
const timeSinceLastRefresh = Date.now() - lastRefreshAt;
|
||||
const shouldRefresh = timeSinceLastRefresh >= TOKEN_REFRESH_DEBOUNCE;
|
||||
|
||||
if (shouldRefresh) {
|
||||
logger.debug(
|
||||
`Time since last refresh: ${Math.round(timeSinceLastRefresh / 1000 / 60)} minutes, exceeds ${TOKEN_REFRESH_DEBOUNCE / 1000 / 60} minutes debounce threshold`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Time since last refresh: ${Math.round(timeSinceLastRefresh / 1000 / 60)} minutes, within ${TOKEN_REFRESH_DEBOUNCE / 1000 / 60} minutes debounce threshold, skipping refresh`,
|
||||
);
|
||||
}
|
||||
|
||||
return shouldRefresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform proactive token refresh (used on startup and app activation)
|
||||
*/
|
||||
private async performProactiveRefresh(): Promise<void> {
|
||||
const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (refreshResult.success) {
|
||||
logger.info('Proactive token refresh successful');
|
||||
this.broadcastTokenRefreshed();
|
||||
this.startAutoRefresh();
|
||||
} else {
|
||||
logger.error(`Proactive token refresh failed: ${refreshResult.error}`);
|
||||
|
||||
// Only clear token for non-retryable errors
|
||||
if (this.remoteServerConfigCtr.isNonRetryableError(refreshResult.error)) {
|
||||
logger.warn('Non-retryable error during proactive refresh, clearing tokens');
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
// For transient errors, still start auto-refresh timer to retry later
|
||||
logger.warn('Transient error during proactive refresh, will retry via auto-refresh');
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle app activation event (e.g., Mac dock click, window focus)
|
||||
* Proactively refresh token if needed (respects 6-hour interval)
|
||||
*/
|
||||
async onAppActivate(): Promise<void> {
|
||||
logger.debug('App activated, checking if token refresh is needed');
|
||||
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
|
||||
// Check if remote server is configured and active
|
||||
if (!(await this.remoteServerConfigCtr.isRemoteServerConfigured(config))) {
|
||||
logger.debug('Remote server not active, skipping activation refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if valid access token exists
|
||||
const accessToken = await this.remoteServerConfigCtr.getAccessToken();
|
||||
if (!accessToken) {
|
||||
logger.debug('No access token found, skipping activation refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only refresh if interval has passed
|
||||
if (this.shouldProactivelyRefresh()) {
|
||||
logger.info('Token refresh interval exceeded on app activation, refreshing token');
|
||||
await this.performProactiveRefresh();
|
||||
} else {
|
||||
logger.debug('Token was recently refreshed, skipping activation refresh');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during app activation refresh check:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,21 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if remote server is properly configured and ready for use
|
||||
* For 'cloud' mode, only checks if active (remoteServerUrl is undefined, uses OFFICIAL_CLOUD_SERVER)
|
||||
* For 'selfHost' mode, checks if active AND remoteServerUrl is configured
|
||||
* @param config Optional config object, if not provided will fetch current config
|
||||
* @returns true if remote server is properly configured
|
||||
*/
|
||||
async isRemoteServerConfigured(config?: DataSyncConfig): Promise<boolean> {
|
||||
const effectiveConfig = config ?? (await this.getRemoteServerConfig());
|
||||
return (
|
||||
effectiveConfig.active &&
|
||||
(effectiveConfig.storageMode !== 'selfHost' || !!effectiveConfig.remoteServerUrl)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set remote server configuration
|
||||
*/
|
||||
@@ -139,6 +154,12 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
*/
|
||||
private tokenExpiresAt?: number;
|
||||
|
||||
/**
|
||||
* Last token refresh time (timestamp in milliseconds)
|
||||
* Used to control refresh frequency on app startup/activate
|
||||
*/
|
||||
private lastRefreshAt?: number;
|
||||
|
||||
/**
|
||||
* Promise representing the ongoing token refresh operation.
|
||||
* Used to prevent concurrent refreshes and allow callers to wait.
|
||||
@@ -162,6 +183,10 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
this.tokenExpiresAt = undefined;
|
||||
}
|
||||
|
||||
// Update last refresh time
|
||||
this.lastRefreshAt = Date.now();
|
||||
logger.debug(`Token last refreshed at: ${new Date(this.lastRefreshAt).toISOString()}`);
|
||||
|
||||
// If platform doesn't support secure storage, store raw tokens
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
logger.warn('Safe storage not available, storing tokens unencrypted');
|
||||
@@ -171,6 +196,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
this.app.storeManager.set(this.encryptedTokensKey, {
|
||||
accessToken: this.encryptedAccessToken,
|
||||
expiresAt: this.tokenExpiresAt,
|
||||
lastRefreshAt: this.lastRefreshAt,
|
||||
refreshToken: this.encryptedRefreshToken,
|
||||
});
|
||||
return;
|
||||
@@ -191,6 +217,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
this.app.storeManager.set(this.encryptedTokensKey, {
|
||||
accessToken: this.encryptedAccessToken,
|
||||
expiresAt: this.tokenExpiresAt,
|
||||
lastRefreshAt: this.lastRefreshAt,
|
||||
refreshToken: this.encryptedRefreshToken,
|
||||
});
|
||||
}
|
||||
@@ -285,10 +312,10 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
|
||||
/**
|
||||
* Check if token is expired or will expire soon
|
||||
* @param bufferTimeMs Buffer time in milliseconds (default 5 minutes)
|
||||
* @param bufferTimeMs Buffer time in milliseconds (default 1 day)
|
||||
* @returns true if token is expired or will expire soon
|
||||
*/
|
||||
isTokenExpiringSoon(bufferTimeMs: number = 5 * 60 * 1000): boolean {
|
||||
isTokenExpiringSoon(bufferTimeMs: number = 24 * 60 * 60 * 1000): boolean {
|
||||
if (!this.tokenExpiresAt) {
|
||||
return false; // No expiration time available
|
||||
}
|
||||
@@ -401,7 +428,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
// Get configuration information
|
||||
const config = await this.getRemoteServerConfig();
|
||||
|
||||
if (!config.remoteServerUrl || !config.active) {
|
||||
if (!(await this.isRemoteServerConfigured(config))) {
|
||||
logger.warn('Remote server not active or configured, skipping refresh.');
|
||||
return { error: 'Remote server is not active or configured', success: false };
|
||||
}
|
||||
@@ -480,17 +507,29 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
this.encryptedAccessToken = storedTokens.accessToken;
|
||||
this.encryptedRefreshToken = storedTokens.refreshToken;
|
||||
this.tokenExpiresAt = storedTokens.expiresAt;
|
||||
this.lastRefreshAt = storedTokens.lastRefreshAt;
|
||||
|
||||
if (this.tokenExpiresAt) {
|
||||
logger.debug(
|
||||
`Loaded token expiration time: ${new Date(this.tokenExpiresAt).toISOString()}`,
|
||||
);
|
||||
}
|
||||
if (this.lastRefreshAt) {
|
||||
logger.debug(`Loaded last refresh time: ${new Date(this.lastRefreshAt).toISOString()}`);
|
||||
}
|
||||
} else {
|
||||
logger.debug('No valid tokens found in store.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last token refresh time
|
||||
* @returns The timestamp (in milliseconds) of the last token refresh, or undefined if never refreshed
|
||||
*/
|
||||
getLastTokenRefreshAt(): number | undefined {
|
||||
return this.lastRefreshAt;
|
||||
}
|
||||
|
||||
// Initialize by loading tokens from store when the controller is ready
|
||||
// We might need a dedicated lifecycle method if constructor is too early for storeManager
|
||||
afterAppReady() {
|
||||
|
||||
@@ -55,8 +55,7 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
logger.debug(`${logPrefix} Received stream:start IPC call`);
|
||||
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
if (!config.active || (config.storageMode === 'selfHost' && !config.remoteServerUrl)) {
|
||||
if (!(await this.remoteServerConfigCtr.isRemoteServerConfigured())) {
|
||||
logger.warn(`${logPrefix} Remote server sync not active or configured.`);
|
||||
event.sender.send(
|
||||
`stream:error:${requestId}`,
|
||||
|
||||
@@ -76,6 +76,7 @@ vi.mock('node:crypto', () => ({
|
||||
const mockRemoteServerConfigCtr = {
|
||||
clearTokens: vi.fn().mockResolvedValue(undefined),
|
||||
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
||||
getLastTokenRefreshAt: vi.fn().mockReturnValue(Date.now()),
|
||||
getRemoteServerConfig: vi.fn().mockResolvedValue({ active: true, storageMode: 'cloud' }),
|
||||
getRemoteServerUrl: vi.fn().mockImplementation(async (config?: DataSyncConfig) => {
|
||||
if (config?.storageMode === 'selfHost') {
|
||||
@@ -84,6 +85,8 @@ const mockRemoteServerConfigCtr = {
|
||||
return 'https://lobehub-cloud.com'; // OFFICIAL_CLOUD_SERVER
|
||||
}),
|
||||
getTokenExpiresAt: vi.fn().mockReturnValue(Date.now() + 3600000),
|
||||
isNonRetryableError: vi.fn().mockReturnValue(false),
|
||||
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
|
||||
isTokenExpiringSoon: vi.fn().mockReturnValue(false),
|
||||
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
|
||||
saveTokens: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -711,4 +714,202 @@ describe('AuthCtr', () => {
|
||||
expect(handoffCalls.length).toBeLessThanOrEqual(5);
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('Proactive Token Refresh', () => {
|
||||
const FIVE_MINUTES = 5 * 60 * 1000; // Debounce interval
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks for proactive refresh tests
|
||||
vi.mocked(mockRemoteServerConfigCtr.getRemoteServerConfig).mockResolvedValue({
|
||||
active: true,
|
||||
remoteServerUrl: 'https://lobehub-cloud.com',
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValue(true);
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue('mock-access-token');
|
||||
vi.mocked(mockRemoteServerConfigCtr.getTokenExpiresAt).mockReturnValue(
|
||||
Date.now() + 3600000, // Token valid for 1 hour
|
||||
);
|
||||
// Reset getLastTokenRefreshAt to a recent value by default
|
||||
// Individual tests will override this as needed
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(Date.now());
|
||||
});
|
||||
|
||||
describe('onAppActivate', () => {
|
||||
it('should refresh token when last refresh was more than 5 minutes ago', async () => {
|
||||
// Last refresh was 10 minutes ago (exceeds 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('tokenRefreshed');
|
||||
});
|
||||
|
||||
it('should NOT refresh token when last refresh was within 5 minutes', async () => {
|
||||
// Last refresh was 2 minutes ago (within 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 2 * 60 * 1000,
|
||||
);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh token when lastRefreshAt is undefined (never refreshed)', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(undefined);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip refresh when remote server is not active', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValue(false);
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip refresh when no access token exists', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue(null);
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle refresh failure with non-retryable error', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
error: 'invalid_grant',
|
||||
success: false,
|
||||
});
|
||||
vi.mocked(mockRemoteServerConfigCtr.isNonRetryableError).mockReturnValue(true);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.clearTokens).toHaveBeenCalled();
|
||||
expect(mockRemoteServerConfigCtr.setRemoteServerConfig).toHaveBeenCalledWith({
|
||||
active: false,
|
||||
});
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationRequired');
|
||||
});
|
||||
|
||||
it('should handle refresh failure with transient error (start auto-refresh)', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
error: 'network_error',
|
||||
success: false,
|
||||
});
|
||||
vi.mocked(mockRemoteServerConfigCtr.isNonRetryableError).mockReturnValue(false);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
// Should not clear tokens for transient errors
|
||||
expect(mockRemoteServerConfigCtr.clearTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady (initializeAutoRefresh)', () => {
|
||||
it('should proactively refresh token on startup when debounce interval exceeded', async () => {
|
||||
// Last refresh was 10 minutes ago (exceeds 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
authCtr.afterAppReady();
|
||||
|
||||
// Wait for async initialization
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT refresh on startup when token was recently refreshed (within debounce)', async () => {
|
||||
// Last refresh was 2 minutes ago (within 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 2 * 60 * 1000,
|
||||
);
|
||||
|
||||
authCtr.afterAppReady();
|
||||
|
||||
// Wait for async initialization
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh on startup when token is expired regardless of last refresh time', async () => {
|
||||
// Token expired 1 hour ago
|
||||
vi.mocked(mockRemoteServerConfigCtr.getTokenExpiresAt).mockReturnValue(
|
||||
Date.now() - 60 * 60 * 1000,
|
||||
);
|
||||
// Last refresh was 2 minutes ago (within debounce, but token is expired)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 2 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
authCtr.afterAppReady();
|
||||
|
||||
// Wait for async initialization
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh debounce boundary tests', () => {
|
||||
it('should NOT refresh at exactly 5 minutes minus 1 second', async () => {
|
||||
// Last refresh was 4 minutes 59 seconds ago
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - (FIVE_MINUTES - 1000),
|
||||
);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh at exactly 5 minutes', async () => {
|
||||
// Last refresh was exactly 5 minutes ago
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - FIVE_MINUTES,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -335,10 +335,10 @@ describe('RemoteServerConfigCtr', () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
|
||||
// Token expires in 1 hour
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
// Token expires in 2 days (well beyond the 24-hour default buffer)
|
||||
await controller.saveTokens('access', 'refresh', 2 * 24 * 3600);
|
||||
|
||||
// Default buffer is 5 minutes
|
||||
// Default buffer is 24 hours
|
||||
const result = controller.isTokenExpiringSoon();
|
||||
|
||||
expect(result).toBe(false);
|
||||
@@ -657,6 +657,56 @@ describe('RemoteServerConfigCtr', () => {
|
||||
// Verify tokens were loaded by checking getTokenExpiresAt
|
||||
expect(newController.getTokenExpiresAt()).toBeDefined();
|
||||
});
|
||||
|
||||
it('should load lastRefreshAt from store', () => {
|
||||
const lastRefreshTime = Date.now() - 3600000; // 1 hour ago
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'encryptedTokens') {
|
||||
return {
|
||||
accessToken: 'stored-access',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
lastRefreshAt: lastRefreshTime,
|
||||
refreshToken: 'stored-refresh',
|
||||
};
|
||||
}
|
||||
return { active: false, storageMode: 'cloud' };
|
||||
});
|
||||
|
||||
const newController = new RemoteServerConfigCtr(mockApp);
|
||||
newController.afterAppReady();
|
||||
|
||||
// Verify lastRefreshAt was loaded
|
||||
expect(newController.getLastTokenRefreshAt()).toBe(lastRefreshTime);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastTokenRefreshAt', () => {
|
||||
it('should return undefined when no tokens have been saved', () => {
|
||||
expect(controller.getLastTokenRefreshAt()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the last refresh time after saving tokens', async () => {
|
||||
const beforeSave = Date.now();
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
const afterSave = Date.now();
|
||||
|
||||
const lastRefreshAt = controller.getLastTokenRefreshAt();
|
||||
|
||||
expect(lastRefreshAt).toBeDefined();
|
||||
expect(lastRefreshAt).toBeGreaterThanOrEqual(beforeSave);
|
||||
expect(lastRefreshAt).toBeLessThanOrEqual(afterSave);
|
||||
});
|
||||
|
||||
it('should persist lastRefreshAt to store when saving tokens', async () => {
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith(
|
||||
'encryptedTokens',
|
||||
expect.objectContaining({
|
||||
lastRefreshAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteServerUrl', () => {
|
||||
@@ -695,4 +745,69 @@ describe('RemoteServerConfigCtr', () => {
|
||||
expect(result).toBe('https://custom-server.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRemoteServerConfigured', () => {
|
||||
it('should return true for active cloud mode (no remoteServerUrl needed)', async () => {
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
// remoteServerUrl is undefined for cloud mode
|
||||
});
|
||||
|
||||
const result = await controller.isRemoteServerConfigured();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for active selfHost mode with remoteServerUrl', async () => {
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: true,
|
||||
remoteServerUrl: 'https://my-server.com',
|
||||
storageMode: 'selfHost',
|
||||
});
|
||||
|
||||
const result = await controller.isRemoteServerConfigured();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for inactive config', async () => {
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
|
||||
const result = await controller.isRemoteServerConfigured();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for selfHost mode without remoteServerUrl', async () => {
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: true,
|
||||
storageMode: 'selfHost',
|
||||
// remoteServerUrl is undefined
|
||||
});
|
||||
|
||||
const result = await controller.isRemoteServerConfigured();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should use provided config instead of fetching', async () => {
|
||||
// Store has inactive config
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
|
||||
// But we provide an active config
|
||||
const result = await controller.isRemoteServerConfigured({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { buildDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import AuthCtr from '@/controllers/AuthCtr';
|
||||
import { IServiceModule } from '@/services';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
@@ -251,6 +252,14 @@ export class App {
|
||||
private onActivate = () => {
|
||||
logger.debug('Application activated');
|
||||
this.browserManager.showMainWindow();
|
||||
|
||||
// Trigger proactive token refresh on app activation (respects 6-hour interval)
|
||||
const authCtr = this.getController(AuthCtr);
|
||||
if (authCtr) {
|
||||
authCtr.onAppActivate().catch((error) => {
|
||||
logger.error('Error during app activation token refresh:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface ElectronMainStore {
|
||||
encryptedTokens: {
|
||||
accessToken?: string;
|
||||
expiresAt?: number;
|
||||
lastRefreshAt?: number;
|
||||
refreshToken?: string;
|
||||
};
|
||||
locale: string;
|
||||
|
||||
Reference in New Issue
Block a user