mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 fix: fix oidc auth timeout issue on the desktop (#10025)
* add tests * fix auth timeout issue * update locale * fix tests
This commit is contained in:
@@ -14,39 +14,38 @@ const logger = createLogger('controllers:AuthCtr');
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
* 使用中间页 + 轮询的方式实现 OAuth 授权流程
|
||||
* Implements OAuth authorization flow using intermediate page + polling mechanism
|
||||
*/
|
||||
export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
* 远程服务器配置控制器
|
||||
* Remote server configuration controller
|
||||
*/
|
||||
private get remoteServerConfigCtr() {
|
||||
return this.app.getController(RemoteServerConfigCtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前的 PKCE 参数
|
||||
* Current PKCE parameters
|
||||
*/
|
||||
private codeVerifier: string | null = null;
|
||||
private authRequestState: string | null = null;
|
||||
|
||||
/**
|
||||
* 轮询相关参数
|
||||
* Polling related parameters
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
private pollingInterval: NodeJS.Timeout | null = null;
|
||||
private cachedRemoteUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* 自动刷新定时器
|
||||
* Auto-refresh timer
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
private autoRefreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* 构造 redirect_uri,确保授权和令牌交换时使用相同的 URI
|
||||
* @param remoteUrl 远程服务器 URL
|
||||
* @param includeHandoffId 是否包含 handoff ID(仅在授权时需要)
|
||||
* Construct redirect_uri, ensuring the same URI is used for authorization and token exchange
|
||||
* @param remoteUrl Remote server URL
|
||||
*/
|
||||
private constructRedirectUri(remoteUrl: string): string {
|
||||
const callbackUrl = new URL('/oidc/callback/desktop', remoteUrl);
|
||||
@@ -59,9 +58,12 @@ export default class AuthCtr extends ControllerModule {
|
||||
*/
|
||||
@ipcClientEvent('requestAuthorization')
|
||||
async requestAuthorization(config: DataSyncConfig) {
|
||||
// 清理任何旧的授权状态
|
||||
this.clearAuthorizationState();
|
||||
|
||||
const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl(config);
|
||||
|
||||
// 缓存远程服务器 URL 用于后续轮询
|
||||
// Cache remote server URL for subsequent polling
|
||||
this.cachedRemoteUrl = remoteUrl;
|
||||
|
||||
logger.info(
|
||||
@@ -115,7 +117,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动轮询机制获取凭证
|
||||
* Start polling mechanism to retrieve credentials
|
||||
*/
|
||||
private startPolling() {
|
||||
if (!this.authRequestState) {
|
||||
@@ -133,7 +135,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
// Check if polling has timed out
|
||||
if (Date.now() - startTime > maxPollTime) {
|
||||
logger.warn('Credential polling timed out');
|
||||
this.stopPolling();
|
||||
this.clearAuthorizationState();
|
||||
this.broadcastAuthorizationFailed('Authorization timed out');
|
||||
return;
|
||||
}
|
||||
@@ -167,14 +169,14 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during credential polling:', error);
|
||||
this.stopPolling();
|
||||
this.clearAuthorizationState();
|
||||
this.broadcastAuthorizationFailed('Polling error: ' + error.message);
|
||||
}
|
||||
}, pollInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止轮询
|
||||
* Stop polling
|
||||
*/
|
||||
private stopPolling() {
|
||||
if (this.pollingInterval) {
|
||||
@@ -184,18 +186,30 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动自动刷新定时器
|
||||
* 清理授权状态
|
||||
* 在开始新的授权流程前或授权失败/超时后调用
|
||||
*/
|
||||
private clearAuthorizationState() {
|
||||
logger.debug('Clearing authorization state');
|
||||
this.stopPolling();
|
||||
this.codeVerifier = null;
|
||||
this.authRequestState = null;
|
||||
this.cachedRemoteUrl = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-refresh timer
|
||||
*/
|
||||
private startAutoRefresh() {
|
||||
// 先停止现有的定时器
|
||||
// Stop existing timer first
|
||||
this.stopAutoRefresh();
|
||||
|
||||
const checkInterval = 2 * 60 * 1000; // 每 2 分钟检查一次
|
||||
const checkInterval = 2 * 60 * 1000; // Check every 2 minutes
|
||||
logger.debug('Starting auto-refresh timer');
|
||||
|
||||
this.autoRefreshTimer = setInterval(async () => {
|
||||
try {
|
||||
// 检查 token 是否即将过期 (提前 5 分钟刷新)
|
||||
// Check if token is expiring soon (refresh 5 minutes in advance)
|
||||
if (this.remoteServerConfigCtr.isTokenExpiringSoon()) {
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
logger.info(
|
||||
@@ -208,7 +222,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
this.broadcastTokenRefreshed();
|
||||
} else {
|
||||
logger.error(`Auto-refresh failed: ${result.error}`);
|
||||
// 如果自动刷新失败,停止定时器并清除 token
|
||||
// If auto-refresh fails, stop timer and clear token
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
@@ -222,7 +236,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止自动刷新定时器
|
||||
* Stop auto-refresh timer
|
||||
*/
|
||||
private stopAutoRefresh() {
|
||||
if (this.autoRefreshTimer) {
|
||||
@@ -233,8 +247,8 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询获取凭证
|
||||
* 直接发送 HTTP 请求到远程服务器
|
||||
* Poll for credentials
|
||||
* Sends HTTP request directly to remote server
|
||||
*/
|
||||
private async pollForCredentials(): Promise<{ code: string; state: string } | null> {
|
||||
if (!this.authRequestState || !this.cachedRemoteUrl) {
|
||||
@@ -242,17 +256,17 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用缓存的远程服务器 URL
|
||||
// Use cached remote server URL
|
||||
const remoteUrl = this.cachedRemoteUrl;
|
||||
|
||||
// 构造请求 URL
|
||||
// Construct request URL
|
||||
const url = new URL('/oidc/handoff', remoteUrl);
|
||||
url.searchParams.set('id', this.authRequestState);
|
||||
url.searchParams.set('client', 'desktop');
|
||||
|
||||
logger.debug(`Polling for credentials: ${url.toString()}`);
|
||||
|
||||
// 直接发送 HTTP 请求
|
||||
// Send HTTP request directly
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -260,9 +274,9 @@ export default class AuthCtr extends ControllerModule {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
// 检查响应状态
|
||||
// Check response status
|
||||
if (response.status === 404) {
|
||||
// 凭证还未准备好,这是正常情况
|
||||
// Credentials not ready yet, this is normal
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -270,7 +284,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 解析响应数据
|
||||
// Parse response data
|
||||
const data = (await response.json()) as {
|
||||
data: {
|
||||
id: string;
|
||||
@@ -511,7 +525,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动后初始化
|
||||
* Initialize after app is ready
|
||||
*/
|
||||
afterAppReady() {
|
||||
logger.debug('AuthCtr initialized, checking for existing tokens');
|
||||
@@ -519,7 +533,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有定时器
|
||||
* Clean up all timers
|
||||
*/
|
||||
cleanup() {
|
||||
logger.debug('Cleaning up AuthCtr timers');
|
||||
@@ -528,14 +542,14 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化自动刷新功能
|
||||
* 在应用启动时检查是否有有效的 token,如果有就启动自动刷新定时器
|
||||
* Initialize auto-refresh functionality
|
||||
* Checks for valid token at app startup and starts auto-refresh timer if token exists
|
||||
*/
|
||||
private async initializeAutoRefresh() {
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
|
||||
// 检查是否配置了远程服务器且处于活动状态
|
||||
// Check if remote server is configured and active
|
||||
if (!config.active || !config.remoteServerUrl) {
|
||||
logger.debug(
|
||||
'Remote server not active or configured, skipping auto-refresh initialization',
|
||||
@@ -543,36 +557,36 @@ export default class AuthCtr extends ControllerModule {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有有效的访问令牌
|
||||
// Check if valid access token exists
|
||||
const accessToken = await this.remoteServerConfigCtr.getAccessToken();
|
||||
if (!accessToken) {
|
||||
logger.debug('No access token found, skipping auto-refresh initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有过期时间信息
|
||||
// Check if token expiration time exists
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
if (!expiresAt) {
|
||||
logger.debug('No token expiration time found, skipping auto-refresh initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 token 是否已经过期
|
||||
// Check if token has already expired
|
||||
const currentTime = Date.now();
|
||||
if (currentTime >= expiresAt) {
|
||||
logger.info('Token has expired, attempting to refresh it');
|
||||
|
||||
// 尝试刷新 token
|
||||
// Attempt to refresh token
|
||||
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}`);
|
||||
// 只有在刷新失败时才清除 token 并要求重新授权
|
||||
// Clear token and require re-authorization only on refresh failure
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
@@ -580,7 +594,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
// 启动自动刷新定时器
|
||||
// Start auto-refresh timer
|
||||
logger.info(
|
||||
`Token is valid, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
|
||||
);
|
||||
|
||||
706
apps/desktop/src/main/controllers/__tests__/AuthCtr.test.ts
Normal file
706
apps/desktop/src/main/controllers/__tests__/AuthCtr.test.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import crypto from 'node:crypto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
|
||||
import AuthCtr from '../AuthCtr';
|
||||
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: vi.fn(() => []),
|
||||
},
|
||||
shell: {
|
||||
openExternal: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((str: string) => Buffer.from(str)),
|
||||
decryptString: vi.fn((buffer: Buffer) => buffer.toString()),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock electron-is
|
||||
vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => false),
|
||||
windows: vi.fn(() => false),
|
||||
linux: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock OFFICIAL_CLOUD_SERVER
|
||||
vi.mock('@/const/env', () => ({
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobehub-cloud.com',
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
isLinux: false,
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
// Mock crypto
|
||||
let randomBytesCounter = 0;
|
||||
vi.mock('node:crypto', () => ({
|
||||
default: {
|
||||
randomBytes: vi.fn((size: number) => {
|
||||
randomBytesCounter++;
|
||||
return {
|
||||
toString: vi.fn(() => `mock-random-${randomBytesCounter}`),
|
||||
};
|
||||
}),
|
||||
subtle: {
|
||||
digest: vi.fn(() => Promise.resolve(new ArrayBuffer(32))),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Create mock App and RemoteServerConfigCtr
|
||||
const mockRemoteServerConfigCtr = {
|
||||
clearTokens: vi.fn().mockResolvedValue(undefined),
|
||||
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
||||
getRemoteServerConfig: vi.fn().mockResolvedValue({ active: true, storageMode: 'cloud' }),
|
||||
getRemoteServerUrl: vi.fn().mockImplementation(async (config?: DataSyncConfig) => {
|
||||
if (config?.storageMode === 'selfHost') {
|
||||
return config.remoteServerUrl || 'https://mock-server.com';
|
||||
}
|
||||
return 'https://lobehub-cloud.com'; // OFFICIAL_CLOUD_SERVER
|
||||
}),
|
||||
getTokenExpiresAt: vi.fn().mockReturnValue(Date.now() + 3600000),
|
||||
isTokenExpiringSoon: vi.fn().mockReturnValue(false),
|
||||
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
|
||||
saveTokens: vi.fn().mockResolvedValue(undefined),
|
||||
setRemoteServerConfig: vi.fn().mockResolvedValue(true),
|
||||
} as unknown as RemoteServerConfigCtr;
|
||||
|
||||
const mockApp = {
|
||||
getController: vi.fn((ControllerClass) => {
|
||||
if (ControllerClass === RemoteServerConfigCtr) {
|
||||
return mockRemoteServerConfigCtr;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
} as unknown as App;
|
||||
|
||||
describe('AuthCtr', () => {
|
||||
let authCtr: AuthCtr;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
let mockWindow: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
randomBytesCounter = 0; // Reset counter for each test
|
||||
|
||||
// Reset shell.openExternal to default successful behavior
|
||||
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
|
||||
|
||||
// Create fresh instance for each test
|
||||
authCtr = new AuthCtr(mockApp);
|
||||
|
||||
// Mock global fetch
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
// Mock BrowserWindow with send spy
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
webContents: {
|
||||
send: vi.fn(),
|
||||
},
|
||||
};
|
||||
vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([mockWindow]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up authCtr intervals (using real timers, not fake timers)
|
||||
authCtr.cleanup();
|
||||
// Clean up any fake timers if used
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
// Use real timers for all tests since setInterval with async doesn't work well with fake timers
|
||||
|
||||
describe('requestAuthorization', () => {
|
||||
it('should generate PKCE parameters and open authorization URL', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
const result = await authCtr.requestAuthorization(config);
|
||||
|
||||
// Verify success response
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
// Verify shell.openExternal was called with correct URL
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://lobehub-cloud.com/oidc/auth'),
|
||||
);
|
||||
|
||||
// Verify URL contains required parameters
|
||||
const authUrl = vi.mocked(shell.openExternal).mock.calls[0][0];
|
||||
expect(authUrl).toContain('client_id=lobehub-desktop');
|
||||
expect(authUrl).toContain('response_type=code');
|
||||
expect(authUrl).toContain('code_challenge_method=S256');
|
||||
expect(authUrl).toContain('scope=profile%20email%20offline_access');
|
||||
});
|
||||
|
||||
it('should start polling after authorization request', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
const result = await authCtr.requestAuthorization(config);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Wait a bit for polling to start
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
|
||||
// Verify fetch was called for polling
|
||||
const pollingCalls = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
);
|
||||
expect(pollingCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should use self-hosted server URL when storageMode is selfHost', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'selfHost',
|
||||
remoteServerUrl: 'https://my-custom-server.com',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Verify shell.openExternal was called with custom URL
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://my-custom-server.com/oidc/auth'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle authorization request error gracefully', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
vi.mocked(shell.openExternal).mockRejectedValue(new Error('Failed to open browser'));
|
||||
|
||||
const result = await authCtr.requestAuthorization(config);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Failed to open browser');
|
||||
});
|
||||
});
|
||||
|
||||
describe('polling mechanism', () => {
|
||||
it('should poll every 3 seconds', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for first poll
|
||||
await new Promise((resolve) => setTimeout(resolve, 3100));
|
||||
|
||||
const firstCallCount = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
).length;
|
||||
expect(firstCallCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Wait for second poll
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const secondCallCount = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
).length;
|
||||
expect(secondCallCount).toBeGreaterThanOrEqual(2);
|
||||
}, 10000);
|
||||
|
||||
it('should stop polling when credentials are received', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
let pollCount = 0;
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Return success on third poll
|
||||
if (urlObj.pathname.includes('/oidc/handoff')) {
|
||||
pollCount++;
|
||||
if (pollCount >= 3) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
payload: {
|
||||
code: 'mock-auth-code',
|
||||
state: 'mock-random-2', // Second randomBytes call is for state
|
||||
},
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Token exchange endpoint
|
||||
if (urlObj.pathname.includes('/oidc/token')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
clone: () => ({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for polling to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
|
||||
const pollCountBefore = pollCount;
|
||||
|
||||
// Wait more time and verify no more polling
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
expect(pollCount).toBe(pollCountBefore);
|
||||
}, 15000);
|
||||
|
||||
it('should broadcast authorizationSuccessful when credentials are exchanged', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
if (urlObj.pathname.includes('/oidc/handoff')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
payload: {
|
||||
code: 'mock-auth-code',
|
||||
state: 'mock-random-2', // Second randomBytes call is for state
|
||||
},
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
});
|
||||
}
|
||||
|
||||
if (urlObj.pathname.includes('/oidc/token')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
clone: () => ({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ status: 404, ok: false });
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for polling to complete and token exchange
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
// Verify authorizationSuccessful was broadcast
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationSuccessful');
|
||||
}, 6000);
|
||||
|
||||
it('should validate state parameter and reject mismatched state', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
if (urlObj.pathname.includes('/oidc/handoff')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
payload: {
|
||||
code: 'mock-auth-code',
|
||||
state: 'wrong-state', // Mismatched state
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ status: 404, ok: false });
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for polling and state validation
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
// Verify authorizationFailed was broadcast with state error
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationFailed', {
|
||||
error: 'Invalid state parameter',
|
||||
});
|
||||
}, 6000);
|
||||
});
|
||||
|
||||
describe('token refresh', () => {
|
||||
it('should start auto-refresh after successful authorization', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
if (urlObj.pathname.includes('/oidc/handoff')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
payload: {
|
||||
code: 'mock-auth-code',
|
||||
state: 'mock-random-2', // Second randomBytes call is for state
|
||||
},
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
});
|
||||
}
|
||||
|
||||
if (urlObj.pathname.includes('/oidc/token')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
clone: () => ({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ status: 404, ok: false });
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for polling and token exchange
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
// Verify saveTokens was called
|
||||
expect(mockRemoteServerConfigCtr.saveTokens).toHaveBeenCalledWith(
|
||||
'new-access-token',
|
||||
'new-refresh-token',
|
||||
3600,
|
||||
);
|
||||
|
||||
// Verify remote server was set to active
|
||||
expect(mockRemoteServerConfigCtr.setRemoteServerConfig).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
});
|
||||
}, 6000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario: Authorization Timeout and Retry', () => {
|
||||
// All scenario tests use real timers
|
||||
|
||||
it('Step 1: User requests authorization but does not complete it within 5 minutes', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
// Mock: User never completes authorization, so polling always returns 404
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
// User clicks "Connect to Cloud" button
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for some polling to happen
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
|
||||
const handoffCallsBeforeTimeout = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
).length;
|
||||
expect(handoffCallsBeforeTimeout).toBeGreaterThan(0);
|
||||
|
||||
// Verify polling is active by checking calls increased
|
||||
const callsBefore = handoffCallsBeforeTimeout;
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
const callsAfter = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
).length;
|
||||
expect(callsAfter).toBeGreaterThan(callsBefore);
|
||||
}, 15000); // Increase test timeout
|
||||
|
||||
it('Step 2: User clicks retry button after previous attempt', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
// First attempt
|
||||
await authCtr.requestAuthorization(config);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
|
||||
// Reset mock to track retry
|
||||
mockFetch.mockClear();
|
||||
|
||||
// User clicks retry button - should start fresh authorization
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Verify: New polling started
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
|
||||
const handoffCalls = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
);
|
||||
expect(handoffCalls.length).toBeGreaterThan(0);
|
||||
}, 10000);
|
||||
|
||||
it('Step 3: Retry generates new state parameter (not reusing old state)', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
const capturedStates: string[] = [];
|
||||
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
const stateParam = urlObj.searchParams.get('id');
|
||||
if (stateParam && !capturedStates.includes(stateParam)) {
|
||||
capturedStates.push(stateParam);
|
||||
}
|
||||
return Promise.resolve({ status: 404, ok: false });
|
||||
});
|
||||
|
||||
// First authorization attempt
|
||||
await authCtr.requestAuthorization(config);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
const firstState = capturedStates[0];
|
||||
|
||||
// Clear for second attempt tracking
|
||||
const firstAttemptStates = [...capturedStates];
|
||||
capturedStates.length = 0;
|
||||
|
||||
// Retry - should generate NEW state
|
||||
await authCtr.requestAuthorization(config);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
const secondState = capturedStates[0];
|
||||
|
||||
// CRITICAL: States must be different
|
||||
expect(firstState).toBeDefined();
|
||||
expect(secondState).toBeDefined();
|
||||
expect(secondState).not.toBe(firstState);
|
||||
expect(firstAttemptStates).not.toContain(secondState);
|
||||
}, 10000);
|
||||
|
||||
it('Step 4: User completes authorization on retry successfully', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
// First attempt - incomplete
|
||||
mockFetch.mockResolvedValue({ status: 404, ok: false });
|
||||
await authCtr.requestAuthorization(config);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3500));
|
||||
|
||||
// Second attempt - user completes it this time
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Handoff returns credentials immediately
|
||||
if (urlObj.pathname.includes('/oidc/handoff')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
payload: {
|
||||
code: 'authorization-code',
|
||||
state: 'mock-random-4', // Matches second request's state (3rd and 4th randomBytes calls)
|
||||
},
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
});
|
||||
}
|
||||
|
||||
// Token exchange succeeds
|
||||
if (urlObj.pathname.includes('/oidc/token')) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: () => Promise.resolve('mock response'),
|
||||
clone: () => ({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ status: 404, ok: false });
|
||||
});
|
||||
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait longer for polling and token exchange
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
// Verify: Success message shown
|
||||
const successCall = mockWindow.webContents.send.mock.calls.find(
|
||||
(call: any[]) => call[0] === 'authorizationSuccessful',
|
||||
);
|
||||
expect(successCall).toBeDefined();
|
||||
|
||||
// Verify: Tokens saved
|
||||
expect(mockRemoteServerConfigCtr.saveTokens).toHaveBeenCalled();
|
||||
}, 12000);
|
||||
|
||||
it('Edge case: Rapid retry clicks should not create multiple polling intervals', async () => {
|
||||
const config: DataSyncConfig = {
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue({ status: 404, ok: false });
|
||||
|
||||
// User rapidly clicks retry multiple times
|
||||
await authCtr.requestAuthorization(config);
|
||||
await authCtr.requestAuthorization(config);
|
||||
await authCtr.requestAuthorization(config);
|
||||
|
||||
// Wait for some polling to happen
|
||||
await new Promise((resolve) => setTimeout(resolve, 9000));
|
||||
|
||||
// Count handoff requests
|
||||
const handoffCalls = mockFetch.mock.calls.filter((call) =>
|
||||
(call[0] as string).includes('/oidc/handoff'),
|
||||
);
|
||||
|
||||
// Should have ~3 calls (one per 3-second interval), not ~9 (3 intervals running)
|
||||
// Allow some tolerance for timing
|
||||
expect(handoffCalls.length).toBeLessThanOrEqual(5);
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user