feat: Add desktop OIDC authentication and onboarding improvements (#11569)

This commit is contained in:
Innei
2026-01-19 20:14:54 +08:00
committed by GitHub
parent 38725da72f
commit 1018679cc8
8 changed files with 209 additions and 34 deletions

View File

@@ -1,4 +1,9 @@
import { DataSyncConfig, MarketAuthorizationParams } from '@lobechat/electron-client-ipc';
import {
AuthorizationPhase,
AuthorizationProgress,
DataSyncConfig,
MarketAuthorizationParams,
} from '@lobechat/electron-client-ipc';
import { BrowserWindow, shell } from 'electron';
import crypto from 'node:crypto';
import querystring from 'node:querystring';
@@ -9,9 +14,11 @@ import { createLogger } from '@/utils/logger';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import { ControllerModule, IpcMethod } from './index';
// Create logger
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
/**
* Authentication Controller
* Implements OAuth authorization flow using intermediate page + polling mechanism
@@ -107,6 +114,12 @@ export default class AuthCtr extends ControllerModule {
await shell.openExternal(authUrl.toString());
logger.debug('Opening authorization URL in default browser');
this.broadcastAuthorizationProgress({
elapsed: 0,
maxPollTime: MAX_POLL_TIME,
phase: 'browser_opened',
});
// Start polling for credentials
this.startPolling();
@@ -117,6 +130,24 @@ export default class AuthCtr extends ControllerModule {
}
}
/**
* Cancel current authorization process
*/
@IpcMethod()
async cancelAuthorization() {
if (this.authRequestState) {
logger.info('User cancelled authorization');
this.clearAuthorizationState();
this.broadcastAuthorizationProgress({
elapsed: 0,
maxPollTime: MAX_POLL_TIME,
phase: 'cancelled',
});
return { success: true };
}
return { error: 'No active authorization', success: false };
}
/**
* Request Market OAuth authorization (desktop)
*/
@@ -152,14 +183,29 @@ export default class AuthCtr extends ControllerModule {
}
logger.info('Starting credential polling');
const pollInterval = 3000; // 3 seconds
const maxPollTime = 5 * 60 * 1000; // 5 minutes
const startTime = Date.now();
// Broadcast initial state
this.broadcastAuthorizationProgress({
elapsed: 0,
maxPollTime: MAX_POLL_TIME,
phase: 'waiting_for_auth',
});
this.pollingInterval = setInterval(async () => {
const elapsed = Date.now() - startTime;
// Broadcast progress on every tick
this.broadcastAuthorizationProgress({
elapsed,
maxPollTime: MAX_POLL_TIME,
phase: 'waiting_for_auth',
});
try {
// Check if polling has timed out
if (Date.now() - startTime > maxPollTime) {
if (elapsed > MAX_POLL_TIME) {
logger.warn('Credential polling timed out');
this.clearAuthorizationState();
this.broadcastAuthorizationFailed('Authorization timed out');
@@ -173,6 +219,13 @@ export default class AuthCtr extends ControllerModule {
logger.info('Successfully received credentials from polling');
this.stopPolling();
// Broadcast verifying state
this.broadcastAuthorizationProgress({
elapsed,
maxPollTime: MAX_POLL_TIME,
phase: 'verifying',
});
// Validate state parameter
if (result.state !== this.authRequestState) {
logger.error(
@@ -198,7 +251,7 @@ export default class AuthCtr extends ControllerModule {
this.clearAuthorizationState();
this.broadcastAuthorizationFailed('Polling error: ' + error.message);
}
}, pollInterval);
}, POLL_INTERVAL);
}
/**
@@ -511,6 +564,21 @@ export default class AuthCtr extends ControllerModule {
}
}
/**
* Broadcast authorization progress event
*/
private broadcastAuthorizationProgress(progress: AuthorizationProgress) {
// Avoid logging too frequently
// logger.debug('Broadcasting authorizationProgress event');
const allWindows = BrowserWindow.getAllWindows();
for (const win of allWindows) {
if (!win.isDestroyed()) {
win.webContents.send('authorizationProgress', progress);
}
}
}
/**
* Broadcast authorization failed event
*/
@@ -563,7 +631,7 @@ export default class AuthCtr extends ControllerModule {
// Hash codeVerifier using SHA-256
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data as unknown as NodeJS.BufferSource);
const digest = await crypto.subtle.digest('SHA-256', data.buffer);
// Convert hash result to base64url encoding
const challenge = Buffer.from(digest)

View File

@@ -58,6 +58,7 @@
"screen4.title": "您希望如何共享数据?",
"screen4.title2": "您的选择将帮助我们改进",
"screen4.title3": "您可以随时在设置中更改",
"screen5.actions.cancel": "取消",
"screen5.actions.connectToServer": "连接服务器",
"screen5.actions.connecting": "正在连接…",
"screen5.actions.signInCloud": "登录 LobeHub Cloud",
@@ -65,6 +66,10 @@
"screen5.actions.signingIn": "正在登录…",
"screen5.actions.signingOut": "正在退出…",
"screen5.actions.tryAgain": "重试",
"screen5.auth.phase.browserOpened": "浏览器已打开,请前往登录…",
"screen5.auth.phase.verifying": "正在验证凭证…",
"screen5.auth.phase.waitingForAuth": "等待授权完成…",
"screen5.auth.remaining": "剩余时间:{{time}}秒",
"screen5.badge": "登录",
"screen5.description": "登录以在所有设备间同步代理、群组、设置和上下文。",
"screen5.errors.desktopOnlyOidc": "OIDC 授权仅在桌面端运行时可用。",

View File

@@ -22,5 +22,9 @@ export type MainBroadcastParams<T extends MainBroadcastEventKey> = Parameters<
MainBroadcastEvents[T]
>[0];
export type { MarketAuthorizationParams } from './remoteServer';
export type {
AuthorizationPhase,
AuthorizationProgress,
MarketAuthorizationParams,
} from './remoteServer';
export type { OpenSettingsWindowOptions } from './windows';

View File

@@ -2,11 +2,34 @@ export interface MarketAuthorizationParams {
authUrl: string;
}
/**
* Authorization phase for progress tracking
*/
export type AuthorizationPhase =
| 'browser_opened' // Browser has been opened for authorization
| 'waiting_for_auth' // Waiting for user to complete browser login
| 'verifying' // Received credentials, verifying with server
| 'cancelled'; // Authorization was cancelled by user
/**
* Authorization progress info for UI updates
*/
export interface AuthorizationProgress {
/** Elapsed time in milliseconds since authorization started */
elapsed: number;
/** Maximum polling time in milliseconds */
maxPollTime: number;
/** Current authorization phase */
phase: AuthorizationPhase;
}
/**
* 从主进程广播的远程服务器相关事件
*/
export interface RemoteServerBroadcastEvents {
authorizationFailed: (params: { error: string }) => void;
/** Broadcast authorization progress for UI updates */
authorizationProgress: (params: AuthorizationProgress) => void;
authorizationRequired: (params: void) => void;
authorizationSuccessful: (params: void) => void;
remoteServerConfigUpdated: (params: void) => void;

View File

@@ -1,6 +1,6 @@
'use client';
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { AuthorizationProgress, useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { Alert, Button, Center, Flexbox, Icon, Input, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { cssVar } from 'antd-style';
@@ -9,6 +9,7 @@ import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { isDesktop } from '@/const/version';
import { remoteServerService } from '@/services/electron/remoteServer';
import { useElectronStore } from '@/store/electron';
import { setDesktopAutoOidcFirstOpenHandled } from '@/utils/electron/autoOidc';
@@ -44,6 +45,7 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
const { t } = useTranslation('desktop-onboarding');
const [endpoint, setEndpoint] = useState('');
const [cloudLoginStatus, setCloudLoginStatus] = useState<LoginStatus>('idle');
const [authProgress, setAuthProgress] = useState<AuthorizationProgress | null>(null);
const [selfhostLoginStatus, setSelfhostLoginStatus] = useState<LoginStatus>('idle');
const [remoteError, setRemoteError] = useState<string | null>(null);
const [isSigningOut, setIsSigningOut] = useState(false);
@@ -164,29 +166,53 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
useWatchBroadcast('authorizationSuccessful', async () => {
setRemoteError(null);
clearRemoteServerSyncError();
setAuthProgress(null);
await refreshServerConfig();
});
useWatchBroadcast('authorizationFailed', ({ error }) => {
setRemoteError(error);
setAuthProgress(null);
if (cloudLoginStatus === 'loading') setCloudLoginStatus('error');
if (selfhostLoginStatus === 'loading') setSelfhostLoginStatus('error');
});
useWatchBroadcast('authorizationProgress', (progress) => {
setAuthProgress(progress);
if (progress.phase === 'cancelled') {
setCloudLoginStatus('idle');
setAuthProgress(null);
}
});
const handleCancelAuth = async () => {
await remoteServerService.cancelAuthorization();
setCloudLoginStatus('idle');
setAuthProgress(null);
};
// 渲染 Cloud 登录内容
const renderCloudContent = () => {
if (cloudLoginStatus === 'success') {
return (
<Button
block
disabled={isSigningOut || isConnectingServer}
icon={Cloud}
onClick={handleSignOut}
size={'large'}
type={'default'}
>
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
</Button>
<Flexbox gap={12} style={{ width: '100%' }}>
<Alert
description={t('authResult.success.desc')}
style={{ width: '100%' }}
title={t('authResult.success.title')}
type={'success'}
/>
<Button
block
disabled={isSigningOut || isConnectingServer}
icon={Cloud}
onClick={handleSignOut}
size={'large'}
type={'default'}
>
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
</Button>
</Flexbox>
);
}
@@ -212,19 +238,43 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
);
}
if (cloudLoginStatus === 'loading') {
return (
<Flexbox gap={8} style={{ width: '100%' }}>
<Button block disabled={true} icon={Cloud} loading={true} size={'large'} type={'primary'}>
{authProgress
? t(`screen5.auth.phase.${authProgress.phase}`, {
defaultValue: t('screen5.actions.signingIn'),
})
: t('screen5.actions.signingIn')}
</Button>
{authProgress && (
<Flexbox align={'center'} horizontal justify={'space-between'}>
<Text style={{ color: cssVar.colorTextDescription }} type={'secondary'}>
{t('screen5.auth.remaining', {
time: Math.round((authProgress.maxPollTime - authProgress.elapsed) / 1000),
})}
</Text>
<Button onClick={handleCancelAuth} size={'small'} type={'text'}>
{t('screen5.actions.cancel')}
</Button>
</Flexbox>
)}
</Flexbox>
);
}
return (
<Button
block
disabled={cloudLoginStatus === 'loading' || isConnectingServer}
disabled={isConnectingServer}
icon={Cloud}
loading={cloudLoginStatus === 'loading'}
loading={false}
onClick={handleCloudLogin}
size={'large'}
type={'primary'}
>
{cloudLoginStatus === 'loading'
? t('screen5.actions.signingIn')
: t('screen5.actions.signInCloud')}
{t('screen5.actions.signInCloud')}
</Button>
);
};
@@ -233,16 +283,24 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
const renderSelfhostContent = () => {
if (selfhostLoginStatus === 'success') {
return (
<Button
block
disabled={isSigningOut || isConnectingServer}
icon={Server}
onClick={handleSignOut}
size={'large'}
type={'default'}
>
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
</Button>
<Flexbox gap={12} style={{ width: '100%' }}>
<Alert
description={t('authResult.success.desc')}
style={{ width: '100%' }}
title={t('authResult.success.title')}
type={'success'}
/>
<Button
block
disabled={isSigningOut || isConnectingServer}
icon={Server}
onClick={handleSignOut}
size={'large'}
type={'default'}
>
{isSigningOut ? t('screen5.actions.signingOut') : t('screen5.actions.signOut')}
</Button>
</Flexbox>
);
}

View File

@@ -2,6 +2,7 @@
import { memo, useEffect } from 'react';
import { getDesktopOnboardingCompleted } from '@/app/[variants]/(desktop)/desktop-onboarding/storage';
import { useElectronStore } from '@/store/electron';
import {
getDesktopAutoOidcFirstOpenHandled,
@@ -28,6 +29,9 @@ const DesktopAutoOidcOnFirstOpen = memo(() => {
useEffect(() => {
if (!isInitRemoteServerConfig) return;
// Don't auto-trigger during onboarding flow.
if (!getDesktopOnboardingCompleted()) return;
// If already connected or not in cloud mode, don't auto-trigger.
if (dataSyncConfig.active) return;
if (dataSyncConfig.storageMode !== 'cloud') return;

View File

@@ -75,11 +75,16 @@ export default {
'screen5.actions.connectToServer': 'Connect to Server',
'screen5.actions.connecting': 'Connecting...',
'screen5.actions.cancel': 'Cancel',
'screen5.actions.signInCloud': 'Sign in to LobeHub Cloud',
'screen5.actions.signOut': 'Sign out',
'screen5.actions.signingIn': 'Signing in...',
'screen5.actions.signingOut': 'Signing out...',
'screen5.actions.tryAgain': 'Try Again',
'screen5.auth.phase.browserOpened': 'Browser opened, please sign in...',
'screen5.auth.phase.waitingForAuth': 'Waiting for authorization...',
'screen5.auth.phase.verifying': 'Verifying credentials...',
'screen5.auth.remaining': 'Remaining: {{time}}s',
'screen5.badge': 'Sign in',
'screen5.description':
'Sign in to sync Agents, Groups, settings, and Context across all devices.',

View File

@@ -1,4 +1,5 @@
import { type DataSyncConfig, type MarketAuthorizationParams } from '@lobechat/electron-client-ipc';
import { ensureElectronIpc } from '@/utils/electron/ipc';
class RemoteServerService {
@@ -36,6 +37,13 @@ class RemoteServerService {
requestMarketAuthorization = async (params: MarketAuthorizationParams) => {
return ensureElectronIpc().auth.requestMarketAuthorization(params);
};
/**
* Cancel authorization
*/
cancelAuthorization = async () => {
return ensureElectronIpc().auth.cancelAuthorization();
};
}
export const remoteServerService = new RemoteServerService();