@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.
394 lines (339 loc) • 13.8 kB
text/typescript
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
import { BrowserWindow, app, shell } from 'electron';
import crypto from 'node:crypto';
import querystring from 'node:querystring';
import { URL } from 'node:url';
import { name } from '@/../../package.json';
import { createLogger } from '@/utils/logger';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import { ControllerModule, ipcClientEvent } from './index';
// Create logger
const logger = createLogger('controllers:AuthCtr');
const protocolPrefix = `com.lobehub.${name}`;
/**
* Authentication Controller
* Used to implement the OAuth authorization flow
*/
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;
beforeAppReady = () => {
this.registerProtocolHandler();
};
/**
* Request OAuth authorization
*/
('requestAuthorization')
async requestAuthorization(config: DataSyncConfig) {
const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl(config);
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
const authUrl = new URL('/oidc/auth', remoteUrl);
// Add query parameters
authUrl.search = querystring.stringify({
client_id: 'lobehub-desktop',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
prompt: 'consent',
redirect_uri: `${protocolPrefix}://auth/callback`,
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');
return { success: true };
} catch (error) {
logger.error('Authorization request failed:', error);
return { error: error.message, success: false };
}
}
/**
* Handle authorization callback
* This method is called when the browser redirects to our custom protocol
*/
async handleAuthCallback(callbackUrl: string) {
logger.info(`Handling authorization callback: ${callbackUrl}`);
try {
const url = new URL(callbackUrl);
const params = new URLSearchParams(url.search);
// Get authorization code
const code = params.get('code');
const state = params.get('state');
logger.debug(`Got parameters from callback URL: code=${code}, state=${state}`);
// Validate state parameter to prevent CSRF attacks
if (state !== this.authRequestState) {
logger.error(
`Invalid state parameter: expected ${this.authRequestState}, received ${state}`,
);
throw new Error('Invalid state parameter');
}
logger.debug('State parameter validation passed');
if (!code) {
logger.error('No authorization code received');
throw new Error('No authorization code received');
}
// Get configuration information
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
logger.debug(`Getting remote server configuration: url=${config.remoteServerUrl}`);
if (config.storageMode === 'selfHost' && !config.remoteServerUrl) {
logger.error('Server URL not configured');
throw new Error('No server URL configured');
}
// Get the previously saved code_verifier
const codeVerifier = this.codeVerifier;
if (!codeVerifier) {
logger.error('Code verifier not found');
throw new Error('No code verifier found');
}
logger.debug('Found code verifier');
// Exchange authorization code for token
logger.debug('Starting to exchange authorization code for token');
const result = await this.exchangeCodeForToken(code, codeVerifier);
if (result.success) {
logger.info('Authorization successful');
// Notify render process of successful authorization
this.broadcastAuthorizationSuccessful();
} else {
logger.warn(`Authorization failed: ${result.error || 'Unknown error'}`);
// Notify render process of failed authorization
this.broadcastAuthorizationFailed(result.error || 'Unknown error');
}
return result;
} catch (error) {
logger.error('Handling authorization callback failed:', error);
// Notify render process of failed authorization
this.broadcastAuthorizationFailed(error.message);
return { error: error.message, success: false };
} finally {
// Clear authorization request state
logger.debug('Clearing authorization request state');
this.authRequestState = null;
this.codeVerifier = null;
}
}
/**
* Refresh access token
*/
('refreshAccessToken')
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();
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');
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 };
}
}
/**
* Register custom protocol handler
*/
private registerProtocolHandler() {
logger.info(`Registering custom protocol handler ${protocolPrefix}://`);
app.setAsDefaultProtocolClient(protocolPrefix);
// Register custom protocol handler
if (process.platform === 'darwin') {
// Handle open-url event on macOS
logger.debug('Registering open-url event handler for macOS');
app.on('open-url', (event, url) => {
event.preventDefault();
logger.info(`Received open-url event: ${url}`);
this.handleAuthCallback(url);
});
} else {
// Handle protocol callback via second-instance event on Windows and Linux
logger.debug('Registering second-instance event handler for Windows/Linux');
app.on('second-instance', async (event, commandLine) => {
// Find the URL from command line arguments
const url = commandLine.find((arg) => arg.startsWith(`${protocolPrefix}://`));
if (url) {
logger.info(`Found URL from second-instance command line arguments: ${url}`);
const { success } = await this.handleAuthCallback(url);
if (success) {
this.app.browserManager.getMainWindow().show();
}
} else {
logger.warn('Protocol URL not found in second-instance command line arguments');
}
});
}
logger.info(`Registered ${protocolPrefix}:// custom protocol handler`);
}
/**
* Exchange authorization code for token
*/
private async exchangeCodeForToken(code: string, codeVerifier: string) {
const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
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: `${protocolPrefix}://auth/callback`,
});
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);
}
// Parse response
const data = await response.json();
logger.debug('Successfully received token exchange response');
// console.log(data); // Keep original log for debugging, or remove/change to logger.debug as needed
// 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);
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 });
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;
}
}