From 46f1699b1c6f716d06c1eaa3dc6aac94dd5761ec Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 03:02:36 -0500 Subject: [PATCH] fix(ghcp): display device code during OAuth device code flow When authenticating with GitHub Copilot using device code flow, the user code was not being displayed. This fix: - Parses device codes from CLIProxy output in oauth-process.ts - Displays code prominently in CLI terminal with box formatting - Broadcasts device code events via WebSocket for UI display - Creates DeviceCodeDialog component for web UI (ccs config) - Follows same pattern as Gemini project selection dialog Closes #189 --- src/cliproxy/auth/oauth-process.ts | 118 ++++++++++-- src/cliproxy/device-code-handler.ts | 167 +++++++++++++++++ src/web-server/websocket.ts | 50 +++++ ui/src/components/layout/layout.tsx | 16 ++ .../components/shared/device-code-dialog.tsx | 174 ++++++++++++++++++ ui/src/components/shared/index.ts | 1 + ui/src/hooks/use-device-code.ts | 134 ++++++++++++++ 7 files changed, 648 insertions(+), 12 deletions(-) create mode 100644 src/cliproxy/device-code-handler.ts create mode 100644 ui/src/components/shared/device-code-dialog.tsx create mode 100644 ui/src/hooks/use-device-code.ts diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index f9e0026e..51848279 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -22,6 +22,7 @@ import { import { ProviderOAuthConfig } from './auth-types'; import { getTimeoutTroubleshooting, showStep } from './environment-detector'; import { isAuthenticated, registerAccountFromToken } from './token-manager'; +import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler'; /** Options for OAuth process execution */ export interface OAuthProcessOptions { @@ -46,6 +47,10 @@ interface ProcessState { accumulatedOutput: string; parsedProjects: GCloudProject[]; sessionId: string; + /** Device code displayed to user (for Device Code Flow) */ + deviceCodeDisplayed: boolean; + /** The user code to enter at verification URL */ + userCode: string | null; } /** @@ -99,6 +104,8 @@ async function handleStdout( log(`stdout: ${output.trim()}`); state.accumulatedOutput += output; + const isDeviceCodeFlow = options.callbackPort === null; + // Parse project list when available if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) { state.parsedProjects = parseProjectList(state.accumulatedOutput); @@ -111,16 +118,62 @@ async function handleStdout( await handleProjectSelection(output, state, options, authProcess, log); } - // Detect callback server / browser - if (!state.browserOpened && (output.includes('listening') || output.includes('http'))) { + // Handle Device Code Flow: parse and display user code + if (isDeviceCodeFlow && !state.deviceCodeDisplayed) { + // Parse device/user code from various formats: + // "Enter code: XXXX-YYYY" or "code XXXX-YYYY" or "user code: XXXX-YYYY" + const codeMatch = state.accumulatedOutput.match( + /(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i + ); + const urlMatch = state.accumulatedOutput.match(/(https?:\/\/[^\s]+device[^\s]*)/i); + + if (codeMatch) { + state.userCode = codeMatch[1].toUpperCase(); + state.deviceCodeDisplayed = true; + log(`Parsed device code: ${state.userCode}`); + + const verificationUrl = urlMatch?.[1] || 'https://github.com/login/device'; + + // Emit device code event for WebSocket broadcast to UI + const deviceCodePrompt: DeviceCodePrompt = { + sessionId: state.sessionId, + provider: options.provider, + userCode: state.userCode, + verificationUrl, + expiresAt: Date.now() + 900000, // 15 minutes + }; + deviceCodeEvents.emit('deviceCode:received', deviceCodePrompt); + + // Display device code prominently in CLI + console.log(''); + console.log(' ╔══════════════════════════════════════════════════════╗'); + console.log(` ║ Enter this code: ${state.userCode.padEnd(35)}║`); + console.log(' ╚══════════════════════════════════════════════════════╝'); + console.log(''); + console.log(info(`Open: ${verificationUrl}`)); + console.log(''); + + // Update step display for device code flow + process.stdout.write('\x1b[1A\x1b[2K'); + showStep(2, 4, 'ok', 'Device code received'); + showStep(3, 4, 'progress', 'Waiting for authorization...'); + } + } + + // Detect callback server / browser (for Authorization Code flows only) + if ( + !isDeviceCodeFlow && + !state.browserOpened && + (output.includes('listening') || output.includes('http')) + ) { process.stdout.write('\x1b[1A\x1b[2K'); showStep(2, 4, 'ok', `Callback server listening on port ${options.callbackPort}`); showStep(3, 4, 'progress', 'Opening browser...'); state.browserOpened = true; } - // Display OAuth URLs in headless mode - if (options.headless && !state.urlDisplayed) { + // Display OAuth URLs in headless mode (for non-device-code flows) + if (!isDeviceCodeFlow && options.headless && !state.urlDisplayed) { const urlMatch = output.match(/https?:\/\/[^\s]+/); if (urlMatch) { console.log(''); @@ -218,6 +271,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { - if (!state.browserOpened) { - process.stdout.write('\x1b[1A\x1b[2K'); - showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`); - showStep(3, 4, 'ok', 'Browser opened'); - state.browserOpened = true; + if (isDeviceCodeFlow) { + // Device Code Flow: show polling message + if (!state.deviceCodeDisplayed) { + // Code not yet displayed, show generic waiting message + showStep(3, 4, 'progress', 'Waiting for device code...'); + } + showStep(4, 4, 'progress', 'Polling for authorization...'); + console.log(''); + console.log( + info('Complete the login in your browser. This page will update automatically.') + ); + } else { + // Authorization Code Flow: show callback server message + if (!state.browserOpened) { + process.stdout.write('\x1b[1A\x1b[2K'); + showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`); + showStep(3, 4, 'ok', 'Browser opened'); + state.browserOpened = true; + } + showStep(4, 4, 'progress', 'Waiting for OAuth callback...'); + console.log(''); + console.log( + info('Complete the login in your browser. This page will update automatically.') + ); } - showStep(4, 4, 'progress', 'Waiting for OAuth callback...'); - console.log(''); - console.log(info('Complete the login in your browser. This page will update automatically.')); if (!verbose) console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose')); }, 2000); @@ -269,12 +341,34 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise void; + timeout: NodeJS.Timeout; +} + +// Global event emitter for device code events +export const deviceCodeEvents = new EventEmitter(); + +// Active sessions by session ID +const activeSessions = new Map(); + +// Default timeout for device code (15 minutes - GitHub's default) +const DEVICE_CODE_TIMEOUT_MS = 900000; + +/** + * Parse device/user code from CLIProxy output + * + * Supports various formats: + * - "Enter code: XXXX-YYYY" + * - "code XXXX-YYYY" + * - "user code: XXXX-YYYY" + * - "code \"XXXX-YYYY\"" + */ +export function parseDeviceCode(output: string): string | null { + const codeMatch = output.match( + /(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i + ); + return codeMatch ? codeMatch[1].toUpperCase() : null; +} + +/** + * Parse verification URL from CLIProxy output + */ +export function parseVerificationUrl(output: string): string | null { + const urlMatch = output.match(/(https?:\/\/[^\s]+device[^\s]*)/i); + return urlMatch ? urlMatch[1] : null; +} + +/** + * Register a device code session and emit event for UI broadcast + * + * @param prompt - Device code prompt data + * @returns Promise that resolves when auth completes or times out + */ +export function registerDeviceCode(prompt: DeviceCodePrompt): Promise { + return new Promise((resolve) => { + // Set timeout for session expiry + const timeout = setTimeout(() => { + const session = activeSessions.get(prompt.sessionId); + if (session) { + activeSessions.delete(prompt.sessionId); + deviceCodeEvents.emit('deviceCode:expired', prompt.sessionId); + resolve(false); + } + }, DEVICE_CODE_TIMEOUT_MS); + + // Store active session + activeSessions.set(prompt.sessionId, { + prompt, + resolve, + timeout, + }); + + // Emit event for WebSocket broadcast to UI + deviceCodeEvents.emit('deviceCode:received', prompt); + }); +} + +/** + * Mark device code session as completed (auth successful) + */ +export function completeDeviceCode(sessionId: string): boolean { + const session = activeSessions.get(sessionId); + + if (!session) { + return false; + } + + clearTimeout(session.timeout); + activeSessions.delete(sessionId); + + session.resolve(true); + deviceCodeEvents.emit('deviceCode:completed', sessionId); + + return true; +} + +/** + * Mark device code session as failed + */ +export function failDeviceCode(sessionId: string, error?: string): boolean { + const session = activeSessions.get(sessionId); + + if (!session) { + return false; + } + + clearTimeout(session.timeout); + activeSessions.delete(sessionId); + + session.resolve(false); + deviceCodeEvents.emit('deviceCode:failed', { sessionId, error }); + + return true; +} + +/** + * Get active device code prompt + */ +export function getActiveDeviceCode(sessionId: string): DeviceCodePrompt | null { + const session = activeSessions.get(sessionId); + return session ? session.prompt : null; +} + +/** + * Check if there's an active device code session + */ +export function hasActiveDeviceCode(sessionId: string): boolean { + return activeSessions.has(sessionId); +} + +/** + * Get all active device code sessions (for debugging/status) + */ +export function getAllActiveDeviceCodes(): DeviceCodePrompt[] { + return Array.from(activeSessions.values()).map((s) => s.prompt); +} + +export default { + parseDeviceCode, + parseVerificationUrl, + registerDeviceCode, + completeDeviceCode, + failDeviceCode, + getActiveDeviceCode, + hasActiveDeviceCode, + getAllActiveDeviceCodes, + deviceCodeEvents, +}; diff --git a/src/web-server/websocket.ts b/src/web-server/websocket.ts index 22de8762..b9ae862e 100644 --- a/src/web-server/websocket.ts +++ b/src/web-server/websocket.ts @@ -12,6 +12,7 @@ import { projectSelectionEvents, type ProjectSelectionPrompt, } from '../cliproxy/project-selection-handler'; +import { deviceCodeEvents, type DeviceCodePrompt } from '../cliproxy/device-code-handler'; export interface WSMessage { type: string; @@ -117,11 +118,54 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { }); }; + // Listen for device code events and broadcast to clients + const handleDeviceCodeReceived = (prompt: DeviceCodePrompt): void => { + console.log(info(`[WS] Broadcasting device code (session: ${prompt.sessionId})`)); + broadcast({ + type: 'deviceCodeReceived', + ...prompt, + timestamp: Date.now(), + }); + }; + + const handleDeviceCodeCompleted = (sessionId: string): void => { + console.log(info(`[WS] Device code auth completed (session: ${sessionId})`)); + broadcast({ + type: 'deviceCodeCompleted', + sessionId, + timestamp: Date.now(), + }); + }; + + const handleDeviceCodeFailed = (data: { sessionId: string; error?: string }): void => { + console.log(info(`[WS] Device code auth failed (session: ${data.sessionId})`)); + broadcast({ + type: 'deviceCodeFailed', + ...data, + timestamp: Date.now(), + }); + }; + + const handleDeviceCodeExpired = (sessionId: string): void => { + console.log(info(`[WS] Device code expired (session: ${sessionId})`)); + broadcast({ + type: 'deviceCodeExpired', + sessionId, + timestamp: Date.now(), + }); + }; + // Subscribe to project selection events projectSelectionEvents.on('selection:required', handleProjectSelectionRequired); projectSelectionEvents.on('selection:timeout', handleProjectSelectionTimeout); projectSelectionEvents.on('selection:submitted', handleProjectSelectionSubmitted); + // Subscribe to device code events + deviceCodeEvents.on('deviceCode:received', handleDeviceCodeReceived); + deviceCodeEvents.on('deviceCode:completed', handleDeviceCodeCompleted); + deviceCodeEvents.on('deviceCode:failed', handleDeviceCodeFailed); + deviceCodeEvents.on('deviceCode:expired', handleDeviceCodeExpired); + // Cleanup function const cleanup = (): void => { watcher.close(); @@ -131,6 +175,12 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { projectSelectionEvents.off('selection:timeout', handleProjectSelectionTimeout); projectSelectionEvents.off('selection:submitted', handleProjectSelectionSubmitted); + // Unsubscribe from device code events + deviceCodeEvents.off('deviceCode:received', handleDeviceCodeReceived); + deviceCodeEvents.off('deviceCode:completed', handleDeviceCodeCompleted); + deviceCodeEvents.off('deviceCode:failed', handleDeviceCodeFailed); + deviceCodeEvents.off('deviceCode:expired', handleDeviceCodeExpired); + clients.forEach((client) => { client.close(1001, 'Server shutting down'); }); diff --git a/ui/src/components/layout/layout.tsx b/ui/src/components/layout/layout.tsx index d04ec791..313beac3 100644 --- a/ui/src/components/layout/layout.tsx +++ b/ui/src/components/layout/layout.tsx @@ -12,7 +12,9 @@ import { Skeleton } from '@/components/ui/skeleton'; import { ClaudeKitBadge } from '@/components/shared/claudekit-badge'; import { SponsorButton } from '@/components/shared/sponsor-button'; import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog'; +import { DeviceCodeDialog } from '@/components/shared/device-code-dialog'; import { useProjectSelection } from '@/hooks/use-project-selection'; +import { useDeviceCode } from '@/hooks/use-device-code'; function PageLoader() { return ( @@ -25,6 +27,7 @@ function PageLoader() { export function Layout() { const { isOpen, prompt, onSelect, onClose } = useProjectSelection(); + const deviceCode = useDeviceCode(); return ( @@ -64,6 +67,19 @@ export function Layout() { onSelect={onSelect} /> )} + + {/* Global device code dialog for Device Code OAuth flows (GitHub Copilot, Qwen) */} + {deviceCode.prompt && ( + + )} ); } diff --git a/ui/src/components/shared/device-code-dialog.tsx b/ui/src/components/shared/device-code-dialog.tsx new file mode 100644 index 00000000..a7224f3e --- /dev/null +++ b/ui/src/components/shared/device-code-dialog.tsx @@ -0,0 +1,174 @@ +/** + * Device Code Dialog Component + * + * Displays during OAuth Device Code flow when user needs to enter + * a verification code at the provider's website (e.g., GitHub, Qwen). + * Shows prominently formatted code with copy and open URL buttons. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { ExternalLink, Copy, Check, Loader2, KeyRound } from 'lucide-react'; +import { toast } from 'sonner'; + +interface DeviceCodeDialogProps { + open: boolean; + onClose: () => void; + sessionId: string; + provider: string; + userCode: string; + verificationUrl: string; + expiresAt: number; +} + +/** Provider display names */ +const PROVIDER_DISPLAY_NAMES: Record = { + ghcp: 'GitHub Copilot', + qwen: 'Qwen Code', +}; + +/** Provider specific instructions */ +const PROVIDER_INSTRUCTIONS: Record = { + ghcp: 'Sign in with your GitHub account that has Copilot access.', + qwen: 'Sign in with your Qwen account to authorize access.', +}; + +export function DeviceCodeDialog({ + open, + onClose, + sessionId, + provider, + userCode, + verificationUrl, + expiresAt, +}: DeviceCodeDialogProps) { + const [hasCopied, setHasCopied] = useState(false); + const [timeRemaining, setTimeRemaining] = useState(null); + + // Calculate and update remaining time + useEffect(() => { + if (!open) return; + + const updateTime = () => { + const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); + setTimeRemaining(remaining); + }; + + updateTime(); + const timer = setInterval(updateTime, 1000); + + return () => clearInterval(timer); + }, [open, expiresAt]); + + const handleCopyCode = useCallback(async () => { + try { + await navigator.clipboard.writeText(userCode); + setHasCopied(true); + toast.success('Code copied to clipboard'); + setTimeout(() => setHasCopied(false), 2000); + } catch { + toast.error('Failed to copy code'); + } + }, [userCode]); + + const handleOpenUrl = useCallback(() => { + window.open(verificationUrl, '_blank', 'noopener,noreferrer'); + }, [verificationUrl]); + + const providerDisplay = PROVIDER_DISPLAY_NAMES[provider] || provider; + const instructions = + PROVIDER_INSTRUCTIONS[provider] || 'Complete the authorization in your browser.'; + + // Format remaining time + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // Suppress unused variable warning - sessionId used for identification + void sessionId; + + return ( + !isOpen && onClose()}> + + + + + Authorize {providerDisplay} + + + Enter the code below at the authorization page. + {timeRemaining !== null && timeRemaining > 0 && ( + + (Expires in {formatTime(timeRemaining)}) + + )} + + + +
+ {/* Large code display */} +
+
+
+ {userCode} +
+
+ +
+ + {/* Instructions */} +
+

{instructions}

+
+ + {/* Action buttons */} +
+ + +
+ + {/* Waiting indicator */} +
+ + Waiting for authorization... +
+
+
+
+ ); +} diff --git a/ui/src/components/shared/index.ts b/ui/src/components/shared/index.ts index 2a181ec4..602a9712 100644 --- a/ui/src/components/shared/index.ts +++ b/ui/src/components/shared/index.ts @@ -8,6 +8,7 @@ export { CodeEditor } from './code-editor'; export { CommandBuilder } from './command-builder'; export { ConfirmDialog } from './confirm-dialog'; export { ConnectionIndicator } from './connection-indicator'; +export { DeviceCodeDialog } from './device-code-dialog'; export { DocsLink } from './docs-link'; export { GitHubLink } from './github-link'; export { GlobalEnvIndicator } from './global-env-indicator'; diff --git a/ui/src/hooks/use-device-code.ts b/ui/src/hooks/use-device-code.ts new file mode 100644 index 00000000..a8492068 --- /dev/null +++ b/ui/src/hooks/use-device-code.ts @@ -0,0 +1,134 @@ +/** + * Device Code Hook + * + * Listens for WebSocket device code events and manages dialog state. + * Similar to useProjectSelection but for Device Code OAuth flows + * (GitHub Copilot, Qwen, etc.) + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { toast } from 'sonner'; + +export interface DeviceCodePrompt { + sessionId: string; + provider: string; + userCode: string; + verificationUrl: string; + expiresAt: number; +} + +interface DeviceCodeState { + isOpen: boolean; + prompt: DeviceCodePrompt | null; + error: string | null; +} + +/** Provider display names for user-friendly messages */ +const PROVIDER_DISPLAY_NAMES: Record = { + ghcp: 'GitHub Copilot', + qwen: 'Qwen Code', +}; + +export function useDeviceCode() { + const [state, setState] = useState({ + isOpen: false, + prompt: null, + error: null, + }); + + // Listen for WebSocket messages via custom events + useEffect(() => { + const handleMessage = (event: CustomEvent<{ type: string; [key: string]: unknown }>) => { + const data = event.detail; + + if (data.type === 'deviceCodeReceived') { + console.log('[DeviceCode] Received prompt:', data.sessionId); + const displayName = PROVIDER_DISPLAY_NAMES[data.provider as string] || data.provider; + toast.info(`${displayName} authorization required`); + + setState({ + isOpen: true, + prompt: { + sessionId: data.sessionId as string, + provider: data.provider as string, + userCode: data.userCode as string, + verificationUrl: data.verificationUrl as string, + expiresAt: data.expiresAt as number, + }, + error: null, + }); + } else if (data.type === 'deviceCodeCompleted') { + console.log('[DeviceCode] Auth completed:', data.sessionId); + setState((prev) => { + if (prev.prompt && prev.prompt.sessionId === data.sessionId) { + const displayName = + PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider; + toast.success(`${displayName} authentication successful!`); + return { isOpen: false, prompt: null, error: null }; + } + return prev; + }); + } else if (data.type === 'deviceCodeFailed') { + console.log('[DeviceCode] Auth failed:', data.sessionId, data.error); + setState((prev) => { + if (prev.prompt && prev.prompt.sessionId === data.sessionId) { + const displayName = + PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider; + toast.error(`${displayName} authentication failed`); + return { isOpen: false, prompt: null, error: data.error as string }; + } + return prev; + }); + } else if (data.type === 'deviceCodeExpired') { + console.log('[DeviceCode] Code expired:', data.sessionId); + setState((prev) => { + if (prev.prompt?.sessionId === data.sessionId) { + toast.error('Device code expired. Please try again.'); + return { isOpen: false, prompt: null, error: 'Device code expired' }; + } + return prev; + }); + } + }; + + // Listen for custom ws-message events dispatched by useWebSocket + window.addEventListener('ws-message', handleMessage as EventListener); + + return () => { + window.removeEventListener('ws-message', handleMessage as EventListener); + }; + }, []); + + const handleClose = useCallback(() => { + setState({ isOpen: false, prompt: null, error: null }); + }, []); + + const handleOpenUrl = useCallback(() => { + if (state.prompt?.verificationUrl) { + window.open(state.prompt.verificationUrl, '_blank', 'noopener,noreferrer'); + } + }, [state.prompt]); + + const handleCopyCode = useCallback(async () => { + if (state.prompt?.userCode) { + try { + await navigator.clipboard.writeText(state.prompt.userCode); + toast.success('Code copied to clipboard'); + } catch { + toast.error('Failed to copy code'); + } + } + }, [state.prompt]); + + return useMemo( + () => ({ + isOpen: state.isOpen, + prompt: state.prompt, + error: state.error, + onClose: handleClose, + onOpenUrl: handleOpenUrl, + onCopyCode: handleCopyCode, + }), + [state.isOpen, state.prompt, state.error, handleClose, handleOpenUrl, handleCopyCode] + ); +}