@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
593 lines (509 loc) • 19.2 kB
text/typescript
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
import { BrowserWindow, shell } from 'electron';
import crypto from 'node:crypto';
import querystring from 'node:querystring';
import { URL } from 'node:url';
import { createLogger } from '@/utils/logger';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import { ControllerModule, ipcClientEvent } from './index';
// Create logger
const logger = createLogger('controllers:AuthCtr');
/**
* Authentication Controller
* 使用中间页 + 轮询的方式实现 OAuth 授权流程
*/
export default class AuthCtr extends ControllerModule {
/**
* 远程服务器配置控制器
*/
private get remoteServerConfigCtr() {
return this.app.getController(RemoteServerConfigCtr);
}
/**
* 当前的 PKCE 参数
*/
private codeVerifier: string | null = null;
private authRequestState: string | null = null;
/**
* 轮询相关参数
*/
// eslint-disable-next-line no-undef
private pollingInterval: NodeJS.Timeout | null = null;
private cachedRemoteUrl: string | null = null;
/**
* 自动刷新定时器
*/
// eslint-disable-next-line no-undef
private autoRefreshTimer: NodeJS.Timeout | null = null;
/**
* 构造 redirect_uri,确保授权和令牌交换时使用相同的 URI
* @param remoteUrl 远程服务器 URL
* @param includeHandoffId 是否包含 handoff ID(仅在授权时需要)
*/
private constructRedirectUri(remoteUrl: string): string {
const callbackUrl = new URL('/oidc/callback/desktop', remoteUrl);
return callbackUrl.toString();
}
/**
* Request OAuth authorization
*/
async requestAuthorization(config: DataSyncConfig) {
const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl(config);
// 缓存远程服务器 URL 用于后续轮询
this.cachedRemoteUrl = remoteUrl;
logger.info(
`Requesting OAuth authorization, storageMode:${config.storageMode} server URL: ${remoteUrl}`,
);
try {
// Generate PKCE parameters
logger.debug('Generating PKCE parameters');
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
this.codeVerifier = codeVerifier;
// Generate state parameter to prevent CSRF attacks
this.authRequestState = crypto.randomBytes(16).toString('hex');
logger.debug(`Generated state parameter: ${this.authRequestState}`);
// Construct authorization URL with new redirect_uri
const authUrl = new URL('/oidc/auth', remoteUrl);
const redirectUri = this.constructRedirectUri(remoteUrl);
logger.info('redirectUri', redirectUri);
// Add query parameters
authUrl.search = querystring.stringify({
client_id: 'lobehub-desktop',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
prompt: 'consent',
redirect_uri: redirectUri,
// https://github.com/lobehub/lobe-chat/pull/8450
resource: 'urn:lobehub:chat',
response_type: 'code',
scope: 'profile email offline_access',
state: this.authRequestState,
});
logger.info(`Constructed authorization URL: ${authUrl.toString()}`);
// Open authorization URL in the default browser
await shell.openExternal(authUrl.toString());
logger.debug('Opening authorization URL in default browser');
// Start polling for credentials
this.startPolling();
return { success: true };
} catch (error) {
logger.error('Authorization request failed:', error);
return { error: error.message, success: false };
}
}
/**
* 启动轮询机制获取凭证
*/
private startPolling() {
if (!this.authRequestState) {
logger.error('No handoff ID available for polling');
return;
}
logger.info('Starting credential polling');
const pollInterval = 3000; // 3 seconds
const maxPollTime = 5 * 60 * 1000; // 5 minutes
const startTime = Date.now();
this.pollingInterval = setInterval(async () => {
try {
// Check if polling has timed out
if (Date.now() - startTime > maxPollTime) {
logger.warn('Credential polling timed out');
this.stopPolling();
this.broadcastAuthorizationFailed('Authorization timed out');
return;
}
// Poll for credentials
const result = await this.pollForCredentials();
if (result) {
logger.info('Successfully received credentials from polling');
this.stopPolling();
// Validate state parameter
if (result.state !== this.authRequestState) {
logger.error(
`Invalid state parameter: expected ${this.authRequestState}, received ${result.state}`,
);
this.broadcastAuthorizationFailed('Invalid state parameter');
return;
}
// Exchange code for tokens
const exchangeResult = await this.exchangeCodeForToken(result.code, this.codeVerifier!);
if (exchangeResult.success) {
logger.info('Authorization successful');
this.broadcastAuthorizationSuccessful();
} else {
logger.warn(`Authorization failed: ${exchangeResult.error || 'Unknown error'}`);
this.broadcastAuthorizationFailed(exchangeResult.error || 'Unknown error');
}
}
} catch (error) {
logger.error('Error during credential polling:', error);
this.stopPolling();
this.broadcastAuthorizationFailed('Polling error: ' + error.message);
}
}, pollInterval);
}
/**
* 停止轮询
*/
private stopPolling() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
/**
* 启动自动刷新定时器
*/
private startAutoRefresh() {
// 先停止现有的定时器
this.stopAutoRefresh();
const checkInterval = 2 * 60 * 1000; // 每 2 分钟检查一次
logger.debug('Starting auto-refresh timer');
this.autoRefreshTimer = setInterval(async () => {
try {
// 检查 token 是否即将过期 (提前 5 分钟刷新)
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'}`,
);
const result = await this.remoteServerConfigCtr.refreshAccessToken();
if (result.success) {
logger.info('Auto-refresh successful');
this.broadcastTokenRefreshed();
} else {
logger.error(`Auto-refresh failed: ${result.error}`);
// 如果自动刷新失败,停止定时器并清除 token
this.stopAutoRefresh();
await this.remoteServerConfigCtr.clearTokens();
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
this.broadcastAuthorizationRequired();
}
}
} catch (error) {
logger.error('Error during auto-refresh check:', error);
}
}, checkInterval);
}
/**
* 停止自动刷新定时器
*/
private stopAutoRefresh() {
if (this.autoRefreshTimer) {
clearInterval(this.autoRefreshTimer);
this.autoRefreshTimer = null;
logger.debug('Stopped auto-refresh timer');
}
}
/**
* 轮询获取凭证
* 直接发送 HTTP 请求到远程服务器
*/
private async pollForCredentials(): Promise<{ code: string; state: string } | null> {
if (!this.authRequestState || !this.cachedRemoteUrl) {
return null;
}
try {
// 使用缓存的远程服务器 URL
const remoteUrl = this.cachedRemoteUrl;
// 构造请求 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 请求
const response = await fetch(url.toString(), {
headers: {
'Content-Type': 'application/json',
},
method: 'GET',
});
// 检查响应状态
if (response.status === 404) {
// 凭证还未准备好,这是正常情况
return null;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// 解析响应数据
const data = (await response.json()) as {
data: {
id: string;
payload: { code: string; state: string };
};
success: boolean;
};
if (data.success && data.data?.payload) {
logger.debug('Successfully retrieved credentials from handoff');
return {
code: data.data.payload.code,
state: data.data.payload.state,
};
}
return null;
} catch (error) {
logger.debug('Polling attempt failed (this is normal):', error.message);
return null;
}
}
/**
* Refresh access token
*/
async refreshAccessToken() {
logger.info('Starting to refresh access token');
try {
// Call the centralized refresh logic in RemoteServerConfigCtr
const result = await this.remoteServerConfigCtr.refreshAccessToken();
if (result.success) {
logger.info('Token refresh successful via AuthCtr call.');
// Notify render process that token has been refreshed
this.broadcastTokenRefreshed();
// Restart auto-refresh timer with new expiration time
this.startAutoRefresh();
return { success: true };
} else {
// Throw an error to be caught by the catch block below
// This maintains the existing behavior of clearing tokens on failure
logger.error(`Token refresh failed via AuthCtr call: ${result.error}`);
throw new Error(result.error || 'Token refresh failed');
}
} catch (error) {
// Keep the existing logic to clear tokens and require re-auth on failure
logger.error('Token refresh operation failed via AuthCtr, initiating cleanup:', error);
// Refresh failed, clear tokens and disable remote server
logger.warn('Refresh failed, clearing tokens and disabling remote server');
this.stopAutoRefresh();
await this.remoteServerConfigCtr.clearTokens();
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
// Notify render process that re-authorization is required
this.broadcastAuthorizationRequired();
return { error: error.message, success: false };
}
}
/**
* Exchange authorization code for token
*/
private async exchangeCodeForToken(code: string, codeVerifier: string) {
if (!this.cachedRemoteUrl) {
throw new Error('No cached remote URL available for token exchange');
}
const remoteUrl = this.cachedRemoteUrl;
logger.info('Starting to exchange authorization code for token');
try {
const tokenUrl = new URL('/oidc/token', remoteUrl);
logger.debug(`Constructed token exchange URL: ${tokenUrl.toString()}`);
// Construct request body
const body = querystring.stringify({
client_id: 'lobehub-desktop',
code,
code_verifier: codeVerifier,
grant_type: 'authorization_code',
redirect_uri: this.constructRedirectUri(remoteUrl),
});
logger.debug('Sending token exchange request');
// Send request to get token
const response = await fetch(tokenUrl.toString(), {
body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
});
if (!response.ok) {
// Try parsing the error response
const errorData = await response.json().catch(() => ({}));
const errorMessage = `Failed to get token: ${response.status} ${response.statusText} ${errorData.error_description || errorData.error || ''}`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
let data;
// Parse response
try {
data = await response.clone().json();
} catch {
const status = response.status;
throw new Error(
`Parse JSON failed, please check your server, response status: ${status}, detail:\n\n ${await response.text()} `,
);
}
logger.debug('Successfully received token exchange response');
// Ensure response contains necessary fields
if (!data.access_token || !data.refresh_token) {
logger.error('Invalid token response: missing access_token or refresh_token');
throw new Error('Invalid token response: missing required fields');
}
// Save tokens
logger.debug('Starting to save exchanged tokens');
await this.remoteServerConfigCtr.saveTokens(
data.access_token,
data.refresh_token,
data.expires_in,
);
logger.info('Successfully saved exchanged tokens');
// Set server to active state
logger.debug(`Setting remote server to active state: ${remoteUrl}`);
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: true });
// Start auto-refresh timer
this.startAutoRefresh();
return { success: true };
} catch (error) {
logger.error('Exchanging authorization code failed:', error);
return { error: error.message, success: false };
}
}
/**
* Broadcast token refreshed event
*/
private broadcastTokenRefreshed() {
logger.debug('Broadcasting tokenRefreshed event to all windows');
const allWindows = BrowserWindow.getAllWindows();
for (const win of allWindows) {
if (!win.isDestroyed()) {
win.webContents.send('tokenRefreshed');
}
}
}
/**
* Broadcast authorization successful event
*/
private broadcastAuthorizationSuccessful() {
logger.debug('Broadcasting authorizationSuccessful event to all windows');
const allWindows = BrowserWindow.getAllWindows();
for (const win of allWindows) {
if (!win.isDestroyed()) {
win.webContents.send('authorizationSuccessful');
}
}
}
/**
* Broadcast authorization failed event
*/
private broadcastAuthorizationFailed(error: string) {
logger.debug(`Broadcasting authorizationFailed event to all windows, error: ${error}`);
const allWindows = BrowserWindow.getAllWindows();
for (const win of allWindows) {
if (!win.isDestroyed()) {
win.webContents.send('authorizationFailed', { error });
}
}
}
/**
* Broadcast authorization required event
*/
private broadcastAuthorizationRequired() {
logger.debug('Broadcasting authorizationRequired event to all windows');
const allWindows = BrowserWindow.getAllWindows();
for (const win of allWindows) {
if (!win.isDestroyed()) {
win.webContents.send('authorizationRequired');
}
}
}
/**
* Generate PKCE codeVerifier
*/
private generateCodeVerifier(): string {
logger.debug('Generating PKCE code verifier');
// Generate a random string of at least 43 characters
const verifier = crypto
.randomBytes(32)
.toString('base64')
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '');
logger.debug('Generated code verifier (partial): ' + verifier.slice(0, 10) + '...'); // Avoid logging full sensitive info
return verifier;
}
/**
* Generate codeChallenge from codeVerifier (S256 method)
*/
private async generateCodeChallenge(codeVerifier: string): Promise<string> {
logger.debug('Generating PKCE code challenge (S256)');
// Hash codeVerifier using SHA-256
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
// Convert hash result to base64url encoding
const challenge = Buffer.from(digest)
.toString('base64')
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '');
logger.debug('Generated code challenge (partial): ' + challenge.slice(0, 10) + '...'); // Avoid logging full sensitive info
return challenge;
}
/**
* 应用启动后初始化
*/
afterAppReady() {
logger.debug('AuthCtr initialized, checking for existing tokens');
this.initializeAutoRefresh();
}
/**
* 清理所有定时器
*/
cleanup() {
logger.debug('Cleaning up AuthCtr timers');
this.stopPolling();
this.stopAutoRefresh();
}
/**
* 初始化自动刷新功能
* 在应用启动时检查是否有有效的 token,如果有就启动自动刷新定时器
*/
private async initializeAutoRefresh() {
try {
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
// 检查是否配置了远程服务器且处于活动状态
if (!config.active || !config.remoteServerUrl) {
logger.debug(
'Remote server not active or configured, skipping auto-refresh initialization',
);
return;
}
// 检查是否有有效的访问令牌
const accessToken = await this.remoteServerConfigCtr.getAccessToken();
if (!accessToken) {
logger.debug('No access token found, skipping auto-refresh initialization');
return;
}
// 检查是否有过期时间信息
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
if (!expiresAt) {
logger.debug('No token expiration time found, skipping auto-refresh initialization');
return;
}
// 检查 token 是否已经过期
const currentTime = Date.now();
if (currentTime >= expiresAt) {
logger.info('Token has expired, attempting to refresh it');
// 尝试刷新 token
const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken();
if (refreshResult.success) {
logger.info('Token refresh successful during initialization');
this.broadcastTokenRefreshed();
// 重新启动自动刷新定时器
this.startAutoRefresh();
return;
} else {
logger.error(`Token refresh failed during initialization: ${refreshResult.error}`);
// 只有在刷新失败时才清除 token 并要求重新授权
await this.remoteServerConfigCtr.clearTokens();
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
this.broadcastAuthorizationRequired();
return;
}
}
// 启动自动刷新定时器
logger.info(
`Token is valid, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
);
this.startAutoRefresh();
} catch (error) {
logger.error('Error during auto-refresh initialization:', error);
}
}
}