diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts new file mode 100644 index 00000000..8309e3e6 --- /dev/null +++ b/apps/x/apps/main/src/auth-server.ts @@ -0,0 +1,119 @@ +import { createServer, Server } from 'http'; +import { URL } from 'url'; + +const OAUTH_CALLBACK_PATH = '/oauth/callback'; +const DEFAULT_PORT = 8080; + +export interface AuthServerResult { + server: Server; + port: number; +} + +/** + * Create a local HTTP server to handle OAuth callback + * Listens on http://localhost:8080/oauth/callback + */ +export function createAuthServer( + port: number = DEFAULT_PORT, + onCallback: (code: string, state: string) => void +): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + if (!req.url) { + res.writeHead(400); + res.end('Bad Request'); + return; + } + + const url = new URL(req.url, `http://localhost:${port}`); + + if (url.pathname === OAUTH_CALLBACK_PATH) { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + + + OAuth Error + + + +

Authorization Failed

+

Error: ${error}

+

You can close this window.

+ + + + `); + return; + } + + if (code && state) { + onCallback(code, state); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + + + Authorization Successful + + + +

Authorization Successful

+

You can close this window.

+ + + + `); + } else { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + + + OAuth Error + + + +

Invalid Request

+

Missing code or state parameter.

+

You can close this window.

+ + + + `); + } + } else { + res.writeHead(404); + res.end('Not Found'); + } + }); + + server.listen(port, 'localhost', () => { + resolve({ server, port }); + }); + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + reject(new Error(`Port ${port} is already in use`)); + } else { + reject(err); + } + }); + }); +} + diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 0be4d6a8..e4ab4e9a 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,5 +1,12 @@ import { ipcMain, BrowserWindow } from 'electron'; import { ipc } from '@x/shared'; +import { + connectProvider, + disconnectProvider, + isConnected, + getConnectedProviders, + listProviders, +} from './oauth-handler.js'; import { watcher as watcherCore, workspace } from '@x/core'; import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; @@ -278,5 +285,20 @@ export function setupIpcHandlers() { await runsCore.stop(args.runId); return { success: true }; }, + 'oauth:connect': async (_event, args) => { + return await connectProvider(args.provider); + }, + 'oauth:disconnect': async (_event, args) => { + return await disconnectProvider(args.provider); + }, + 'oauth:is-connected': async (_event, args) => { + return await isConnected(args.provider); + }, + 'oauth:list-providers': async () => { + return listProviders(); + }, + 'oauth:get-connected-providers': async () => { + return await getConnectedProviders(); + }, }); } \ No newline at end of file diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts new file mode 100644 index 00000000..3bae6556 --- /dev/null +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -0,0 +1,197 @@ +import { BrowserWindow } from 'electron'; +import { randomBytes } from 'crypto'; +import { createAuthServer } from './auth-server.js'; +import { generateCodeVerifier, generateCodeChallenge } from '@x/core/dist/auth/pkce.js'; +import { createOAuthService } from '@x/core/dist/auth/oauth.js'; +import { getAvailableProviders } from '@x/core/dist/auth/providers.js'; +import container from '@x/core/dist/di/container.js'; +import { IOAuthRepo } from '@x/core/dist/auth/repo.js'; + +const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; + +// Store active OAuth flows (state -> { codeVerifier, provider }) +const activeFlows = new Map(); + +/** + * Generate a random state string for CSRF protection + */ +function generateState(): string { + return randomBytes(32).toString('hex'); +} + +/** + * Get OAuth repository from DI container + */ +function getOAuthRepo(): IOAuthRepo { + return container.resolve('oauthRepo'); +} + +/** + * Initiate OAuth flow for a provider + */ +export async function connectProvider(provider: string): Promise<{ success: boolean; error?: string }> { + try { + const oauthService = createOAuthService(provider); + const oauthRepo = getOAuthRepo(); + + // Generate PKCE codes + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = generateState(); + + // Store flow state + activeFlows.set(state, { codeVerifier, provider }); + + // Create callback server + const { server } = await createAuthServer(8080, async (code, receivedState) => { + // Validate state + if (receivedState !== state) { + throw new Error('Invalid state parameter - possible CSRF attack'); + } + + const flow = activeFlows.get(state); + if (!flow || flow.provider !== provider) { + throw new Error('Invalid OAuth flow state'); + } + + try { + // Exchange code for tokens + const tokens = await oauthService.exchangeCodeForTokens( + code, + flow.codeVerifier, + REDIRECT_URI + ); + + // Save tokens + await oauthRepo.saveTokens(provider, tokens); + } catch (error) { + console.error('OAuth token exchange failed:', error); + throw error; + } finally { + // Clean up + activeFlows.delete(state); + server.close(); + } + }); + + // Build authorization URL + const authUrl = oauthService.buildAuthorizationUrl(codeChallenge, state, REDIRECT_URI); + + // Open browser window + const authWindow = new BrowserWindow({ + width: 600, + height: 700, + show: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }); + + authWindow.loadURL(authUrl); + + // Clean up on window close + authWindow.on('closed', () => { + activeFlows.delete(state); + server.close(); + }); + + // Wait for callback (server will handle it) + return { success: true }; + } catch (error) { + console.error('OAuth connection failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * Disconnect a provider (clear tokens) + */ +export async function disconnectProvider(provider: string): Promise<{ success: boolean }> { + try { + const oauthRepo = getOAuthRepo(); + await oauthRepo.clearTokens(provider); + return { success: true }; + } catch (error) { + console.error('OAuth disconnect failed:', error); + return { success: false }; + } +} + +/** + * Check if a provider is connected + */ +export async function isConnected(provider: string): Promise<{ isConnected: boolean }> { + try { + const oauthRepo = getOAuthRepo(); + const connected = await oauthRepo.isConnected(provider); + return { isConnected: connected }; + } catch (error) { + console.error('OAuth connection check failed:', error); + return { isConnected: false }; + } +} + +/** + * Get access token for a provider (internal use only) + * Refreshes token if expired + */ +export async function getAccessToken(provider: string): Promise { + try { + const oauthRepo = getOAuthRepo(); + const oauthService = createOAuthService(provider); + + let tokens = await oauthRepo.getTokens(provider); + if (!tokens) { + return null; + } + + // Check if token needs refresh + if (oauthService.isTokenExpired(tokens)) { + if (!tokens.refresh_token) { + // No refresh token, need to reconnect + return null; + } + + try { + // Refresh token, preserving existing scopes + const existingScopes = (tokens).scopes; + tokens = await oauthService.refreshAccessToken(tokens.refresh_token, existingScopes); + await oauthRepo.saveTokens(provider, tokens); + } catch (error) { + console.error('Token refresh failed:', error); + return null; + } + } + + return tokens.access_token; + } catch (error) { + console.error('Get access token failed:', error); + return null; + } +} + +/** + * Get list of connected providers + */ +export async function getConnectedProviders(): Promise<{ providers: string[] }> { + try { + const oauthRepo = getOAuthRepo(); + const providers = await oauthRepo.getConnectedProviders(); + return { providers }; + } catch (error) { + console.error('Get connected providers failed:', error); + return { providers: [] }; + } +} + +/** + * Get list of available providers + */ +export function listProviders(): { providers: string[] } { + return { providers: getAvailableProviders() }; +} + diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a8ce0c2c..4398f7e5 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -201,6 +201,9 @@ function buildTree(entries: DirEntry[]): TreeNode[] { } function App() { + // Sidebar view state + const [activeSidebarView, setActiveSidebarView] = useState<'files' | 'accounts'>('files') + // File browser state const [tree, setTree] = useState([]) const [expandedPaths, setExpandedPaths] = useState>(new Set()) @@ -577,6 +580,8 @@ function App() { selectedPath={selectedPath} expandedPaths={expandedPaths} onSelectFile={toggleExpand} + activeView={activeSidebarView} + onViewChange={setActiveSidebarView} />
diff --git a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx index 5ede475f..a529af66 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx @@ -666,7 +666,7 @@ export const PromptInput = ({ } } }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current + [usingProvider] ); diff --git a/apps/x/apps/renderer/src/components/app-sidebar.tsx b/apps/x/apps/renderer/src/components/app-sidebar.tsx index db00ea0f..05bffa77 100644 --- a/apps/x/apps/renderer/src/components/app-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/app-sidebar.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { ChevronRight, File, Folder } from "lucide-react" +import { ChevronRight, File, Folder, Plug } from "lucide-react" import { Collapsible, CollapsibleContent, @@ -19,6 +19,7 @@ import { SidebarMenuSub, useSidebar, } from "@/components/ui/sidebar" +import { ConnectedAccountsSidebar } from "@/components/connected-accounts-sidebar" type TreeNode = { name: string @@ -27,11 +28,15 @@ type TreeNode = { children?: TreeNode[] } +type SidebarView = 'files' | 'accounts' + type AppSidebarProps = React.ComponentProps & { tree: TreeNode[] selectedPath: string | null expandedPaths: Set onSelectFile: (path: string, kind: 'file' | 'dir') => void + activeView: SidebarView + onViewChange: (view: SidebarView) => void } export function AppSidebar({ @@ -39,6 +44,8 @@ export function AppSidebar({ selectedPath, expandedPaths, onSelectFile, + activeView, + onViewChange, ...props }: AppSidebarProps) { const { setOpen } = useSidebar() @@ -64,15 +71,30 @@ export function AppSidebar({ { + onViewChange('files') setOpen(true) }} - isActive={true} + isActive={activeView === 'files'} className="px-2.5 md:px-2" > Files + + { + onViewChange('accounts') + setOpen(true) + }} + isActive={activeView === 'accounts'} + className="px-2.5 md:px-2" + > + + Connected Accounts + + @@ -81,26 +103,30 @@ export function AppSidebar({ {/* This is the second sidebar */} {/* We disable collapsible and let it fill remaining space */} - - - - Files - - - {tree.map((item, index) => ( - - ))} - - - - - + {activeView === 'files' ? ( + + + + Files + + + {tree.map((item, index) => ( + + ))} + + + + + + ) : ( + + )} ) } diff --git a/apps/x/apps/renderer/src/components/connected-accounts-sidebar.tsx b/apps/x/apps/renderer/src/components/connected-accounts-sidebar.tsx new file mode 100644 index 00000000..e3069fe4 --- /dev/null +++ b/apps/x/apps/renderer/src/components/connected-accounts-sidebar.tsx @@ -0,0 +1,112 @@ +"use client" + +import * as React from "react" +import { Loader2, Plug } from "lucide-react" +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { useOAuth, useAvailableProviders } from "@/hooks/useOAuth" + +type ConnectedAccountsSidebarProps = React.ComponentProps + +export function ConnectedAccountsSidebar({ ...props }: ConnectedAccountsSidebarProps) { + const { providers, isLoading: providersLoading } = useAvailableProviders() + + return ( + + + + Connected Accounts + + + {providersLoading ? ( + + + + Loading... + + + ) : providers.length === 0 ? ( + + + No providers available + + + ) : ( + providers.map((provider) => ( + + )) + )} + + + + + + ) +} + +function ProviderItem({ provider }: { provider: string }) { + const { isConnected, isLoading, isConnecting, connect, disconnect } = useOAuth(provider) + const providerDisplayName = provider.charAt(0).toUpperCase() + provider.slice(1) + + return ( + +
+
+ + {providerDisplayName} + {isLoading ? ( + + ) : ( + + {isConnected ? "Connected" : "Not Connected"} + + )} +
+
+ {isConnected ? ( + + ) : ( + + )} +
+
+
+ ) +} + diff --git a/apps/x/apps/renderer/src/hooks/useOAuth.ts b/apps/x/apps/renderer/src/hooks/useOAuth.ts new file mode 100644 index 00000000..10d69e6b --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useOAuth.ts @@ -0,0 +1,128 @@ +import { useState, useEffect, useCallback } from 'react'; +import { toast } from '@/lib/toast'; + +/** + * Hook for managing OAuth connection state for a specific provider + */ +export function useOAuth(provider: string) { + const [isConnected, setIsConnected] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + + // Check connection status on mount and when provider changes + useEffect(() => { + checkConnection(); + }, [provider]); + + const checkConnection = useCallback(async () => { + try { + setIsLoading(true); + const result = await window.ipc.invoke('oauth:is-connected', { provider }); + setIsConnected(result.isConnected); + } catch (error) { + console.error('Failed to check connection status:', error); + setIsConnected(false); + } finally { + setIsLoading(false); + } + }, [provider]); + + const connect = useCallback(async () => { + try { + setIsConnecting(true); + const result = await window.ipc.invoke('oauth:connect', { provider }); + if (result.success) { + toast(`Successfully connected to ${provider}`, 'success'); + await checkConnection(); + } else { + toast(result.error || `Failed to connect to ${provider}`, 'error'); + } + } catch (error) { + console.error('Failed to connect:', error); + toast(`Failed to connect to ${provider}`, 'error'); + } finally { + setIsConnecting(false); + } + }, [provider, checkConnection]); + + const disconnect = useCallback(async () => { + try { + setIsLoading(true); + const result = await window.ipc.invoke('oauth:disconnect', { provider }); + if (result.success) { + toast(`Disconnected from ${provider}`, 'success'); + setIsConnected(false); + } else { + toast(`Failed to disconnect from ${provider}`, 'error'); + } + } catch (error) { + console.error('Failed to disconnect:', error); + toast(`Failed to disconnect from ${provider}`, 'error'); + } finally { + setIsLoading(false); + } + }, [provider]); + + return { + isConnected, + isLoading, + isConnecting, + connect, + disconnect, + refresh: checkConnection, + }; +} + +/** + * Hook to get list of connected providers + */ +export function useConnectedProviders() { + const [providers, setProviders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + setIsLoading(true); + const result = await window.ipc.invoke('oauth:get-connected-providers', null); + setProviders(result.providers); + } catch (error) { + console.error('Failed to get connected providers:', error); + setProviders([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { providers, isLoading, refresh }; +} + +/** + * Hook to get list of available providers + */ +export function useAvailableProviders() { + const [providers, setProviders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function load() { + try { + setIsLoading(true); + const result = await window.ipc.invoke('oauth:list-providers', null); + setProviders(result.providers); + } catch (error) { + console.error('Failed to get available providers:', error); + setProviders([]); + } finally { + setIsLoading(false); + } + } + load(); + }, []); + + return { providers, isLoading }; +} + diff --git a/apps/x/apps/renderer/src/lib/toast.ts b/apps/x/apps/renderer/src/lib/toast.ts new file mode 100644 index 00000000..0fdeb92e --- /dev/null +++ b/apps/x/apps/renderer/src/lib/toast.ts @@ -0,0 +1,59 @@ +/** + * Simple toast notification system + */ + +type ToastType = 'success' | 'error' | 'info'; + +interface Toast { + id: string; + message: string; + type: ToastType; +} + +let toasts: Toast[] = []; +const listeners: Set<() => void> = new Set(); + +/** + * Show a toast notification + */ +export function toast(message: string, type: ToastType = 'info'): void { + const id = `${Date.now()}-${Math.random()}`; + toasts.push({ id, message, type }); + notifyListeners(); + + // Auto-remove after 3 seconds + setTimeout(() => { + toasts = toasts.filter(t => t.id !== id); + notifyListeners(); + }, 3000); +} + +/** + * Get current toasts + */ +export function getToasts(): Toast[] { + return [...toasts]; +} + +/** + * Subscribe to toast changes + */ +export function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function notifyListeners(): void { + listeners.forEach(listener => listener()); +} + +/** + * Remove a toast by ID + */ +export function removeToast(id: string): void { + toasts = toasts.filter(t => t.id !== id); + notifyListeners(); +} + diff --git a/apps/x/packages/core/src/auth/oauth.ts b/apps/x/packages/core/src/auth/oauth.ts new file mode 100644 index 00000000..953c80eb --- /dev/null +++ b/apps/x/packages/core/src/auth/oauth.ts @@ -0,0 +1,150 @@ +import { OAuthProviderConfig, getProviderConfig } from './providers.js'; +import { OAuthTokens } from '@x/shared/dist/auth.js'; + +/** + * Generic OAuth 2.0 service with PKCE support + */ +export class OAuthService { + constructor(private config: OAuthProviderConfig) {} + + /** + * Build authorization URL with PKCE parameters + */ + buildAuthorizationUrl( + codeChallenge: string, + state: string, + redirectUri: string + ): string { + const params = new URLSearchParams({ + client_id: this.config.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: this.config.scopes.join(' '), + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + + return `${this.config.authorizationEndpoint}?${params.toString()}`; + } + + /** + * Exchange authorization code for tokens using PKCE + */ + async exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string + ): Promise { + const params = new URLSearchParams({ + client_id: this.config.clientId, + code, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + code_verifier: codeVerifier, + }); + + const response = await fetch(this.config.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + + // Calculate expires_at from expires_in if provided + const expiresIn = data.expires_in || 3600; // Default to 1 hour + const expiresAt = Math.floor(Date.now() / 1000) + expiresIn; + + // Extract granted scopes from response (may be space-separated string or array) + let scopes: string[] | undefined; + if (data.scope) { + if (typeof data.scope === 'string') { + scopes = data.scope.split(' ').filter((s: string) => s.length > 0); + } else if (Array.isArray(data.scope)) { + scopes = data.scope; + } + } + + return OAuthTokens.parse({ + access_token: data.access_token, + refresh_token: data.refresh_token || null, + expires_at: expiresAt, + token_type: data.token_type || 'Bearer', + scopes, + }); + } + + /** + * Refresh access token using refresh token + * Preserves existing scopes since refresh responses typically don't include them + */ + async refreshAccessToken(refreshToken: string, existingScopes?: string[]): Promise { + const params = new URLSearchParams({ + client_id: this.config.clientId, + refresh_token: refreshToken, + grant_type: 'refresh_token', + }); + + const response = await fetch(this.config.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token refresh failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + + // Calculate expires_at from expires_in if provided + const expiresIn = data.expires_in || 3600; + const expiresAt = Math.floor(Date.now() / 1000) + expiresIn; + + // Extract scopes from refresh response if provided, otherwise preserve existing scopes + let scopes: string[] | undefined = existingScopes; + if (data.scope) { + if (typeof data.scope === 'string') { + scopes = data.scope.split(' ').filter((s: string) => s.length > 0); + } else if (Array.isArray(data.scope)) { + scopes = data.scope; + } + } + + return OAuthTokens.parse({ + access_token: data.access_token, + refresh_token: data.refresh_token || refreshToken, // Some providers don't return new refresh token + expires_at: expiresAt, + token_type: data.token_type || 'Bearer', + scopes, + }); + } + + /** + * Check if tokens are expired + */ + isTokenExpired(tokens: OAuthTokens): boolean { + const now = Math.floor(Date.now() / 1000); + return tokens.expires_at <= now; + } +} + +/** + * Factory function to create OAuth service for a provider + */ +export function createOAuthService(providerName: string): OAuthService { + const config = getProviderConfig(providerName); + return new OAuthService(config); +} + diff --git a/apps/x/packages/core/src/auth/pkce.ts b/apps/x/packages/core/src/auth/pkce.ts new file mode 100644 index 00000000..53bfcc63 --- /dev/null +++ b/apps/x/packages/core/src/auth/pkce.ts @@ -0,0 +1,34 @@ +import { randomBytes, createHash } from 'crypto'; + +/** + * Generate a random code verifier for PKCE + * Returns a base64url-encoded string of 128 characters + */ +export function generateCodeVerifier(): string { + // Generate 96 random bytes (768 bits) to ensure we have enough entropy + // After base64url encoding, this will be 128 characters + const bytes = randomBytes(96); + return base64UrlEncode(bytes); +} + +/** + * Generate a code challenge from a code verifier + * Uses SHA256 hash and base64url encoding + */ +export function generateCodeChallenge(verifier: string): string { + const hash = createHash('sha256').update(verifier).digest(); + return base64UrlEncode(hash); +} + +/** + * Base64url encode (RFC 4648) + * Replaces + with -, / with _, and removes padding + */ +function base64UrlEncode(buffer: Buffer): string { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts new file mode 100644 index 00000000..9b58a235 --- /dev/null +++ b/apps/x/packages/core/src/auth/providers.ts @@ -0,0 +1,52 @@ +/** + * OAuth 2.0 provider configuration + */ +export interface OAuthProviderConfig { + name: string; + clientId: string; + authorizationEndpoint: string; + tokenEndpoint: string; + scopes: string[]; +} + +/** + * Get Google OAuth provider configuration + */ +export function getGoogleProviderConfig(): OAuthProviderConfig { + // TODO: Replace with actual Google OAuth client ID + const GOOGLE_CLIENT_ID = '797410052581-ibmmvqec0l68stv5fmgh0juqfvbg08fc.apps.googleusercontent.com' + + return { + name: 'google', + clientId: GOOGLE_CLIENT_ID, + authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenEndpoint: 'https://oauth2.googleapis.com/token', + scopes: [ + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/calendar.readonly', + 'https://www.googleapis.com/auth/drive.readonly', + ], + }; +} + +/** + * Get list of all configured OAuth providers + */ +export function getAvailableProviders(): string[] { + return ['google']; + // Future: Add more providers here + // return ['google', 'github', 'microsoft']; +} + +/** + * Get provider configuration by name + */ +export function getProviderConfig(providerName: string): OAuthProviderConfig { + switch (providerName) { + case 'google': + return getGoogleProviderConfig(); + default: + throw new Error(`Unknown OAuth provider: ${providerName}`); + } +} + diff --git a/apps/x/packages/core/src/auth/repo.ts b/apps/x/packages/core/src/auth/repo.ts new file mode 100644 index 00000000..0add78c1 --- /dev/null +++ b/apps/x/packages/core/src/auth/repo.ts @@ -0,0 +1,101 @@ +import { WorkDir } from '../config/config.js'; +import fs from 'fs/promises'; +import path from 'path'; +import { OAuthTokens } from '@x/shared/dist/auth.js'; + +export interface IOAuthRepo { + getTokens(provider: string): Promise; + saveTokens(provider: string, tokens: OAuthTokens): Promise; + clearTokens(provider: string): Promise; + isConnected(provider: string): Promise; + getConnectedProviders(): Promise; +} + +type OAuthStorage = { + [provider: string]: OAuthTokens; +}; + +export class FSOAuthRepo implements IOAuthRepo { + private readonly configPath = path.join(WorkDir, 'config', 'oauth.json'); + + constructor() { + this.ensureConfigFile(); + } + + private async ensureConfigFile(): Promise { + try { + await fs.access(this.configPath); + } catch { + // File doesn't exist, create it with empty object + await fs.writeFile(this.configPath, JSON.stringify({}, null, 2)); + } + } + + private async readConfig(): Promise { + try { + const content = await fs.readFile(this.configPath, 'utf8'); + const parsed = JSON.parse(content); + return parsed as OAuthStorage; + } catch { + return {}; + } + } + + private async writeConfig(config: OAuthStorage): Promise { + await fs.writeFile(this.configPath, JSON.stringify(config, null, 2)); + } + + async getTokens(provider: string): Promise { + const config = await this.readConfig(); + const tokens = config[provider]; + if (!tokens) { + return null; + } + + // Validate tokens structure + try { + return OAuthTokens.parse(tokens); + } catch { + // Invalid tokens, remove them + await this.clearTokens(provider); + return null; + } + } + + async saveTokens(provider: string, tokens: OAuthTokens): Promise { + const config = await this.readConfig(); + config[provider] = tokens; + await this.writeConfig(config); + } + + async clearTokens(provider: string): Promise { + const config = await this.readConfig(); + delete config[provider]; + await this.writeConfig(config); + } + + async isConnected(provider: string): Promise { + const tokens = await this.getTokens(provider); + if (!tokens) { + return false; + } + + // Check if token is expired + const now = Math.floor(Date.now() / 1000); + return tokens.expires_at > now; + } + + async getConnectedProviders(): Promise { + const config = await this.readConfig(); + const connected: string[] = []; + + for (const provider of Object.keys(config)) { + if (await this.isConnected(provider)) { + connected.push(provider); + } + } + + return connected; + } +} + diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index bfcd5a47..855bca05 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -8,6 +8,7 @@ import { IMessageQueue, InMemoryMessageQueue } from "../application/lib/message- import { IBus, InMemoryBus } from "../application/lib/bus.js"; import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js"; import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js"; +import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -25,6 +26,7 @@ container.register({ modelConfigRepo: asClass(FSModelConfigRepo).singleton(), agentsRepo: asClass(FSAgentsRepo).singleton(), runsRepo: asClass(FSRunsRepo).singleton(), + oauthRepo: asClass(FSOAuthRepo).singleton(), }); export default container; \ No newline at end of file diff --git a/apps/x/packages/shared/src/auth.ts b/apps/x/packages/shared/src/auth.ts new file mode 100644 index 00000000..79b9dfe9 --- /dev/null +++ b/apps/x/packages/shared/src/auth.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +/** + * OAuth 2.0 tokens structure + */ +export const OAuthTokens = z.object({ + access_token: z.string(), + refresh_token: z.string().nullable(), + expires_at: z.number(), // Unix timestamp + token_type: z.literal('Bearer').optional(), + scopes: z.array(z.string()).optional(), // Granted scopes from OAuth response +}); + +export type OAuthTokens = z.infer; + diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 878c1043..008ebab5 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -3,4 +3,5 @@ import { PrefixLogger } from './prefix-logger.js'; export * as ipc from './ipc.js'; export * as workspace from './workspace.js'; export * as mcp from './mcp.js'; +export * as auth from './auth.js'; export { PrefixLogger }; \ No newline at end of file diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index adda26eb..5554d3b2 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -159,7 +159,44 @@ const ipcSchemas = { 'runs:events': { req: z.null(), res: z.null(), - } + }, + 'oauth:connect': { + req: z.object({ + provider: z.string(), + }), + res: z.object({ + success: z.boolean(), + error: z.string().optional(), + }), + }, + 'oauth:disconnect': { + req: z.object({ + provider: z.string(), + }), + res: z.object({ + success: z.boolean(), + }), + }, + 'oauth:is-connected': { + req: z.object({ + provider: z.string(), + }), + res: z.object({ + isConnected: z.boolean(), + }), + }, + 'oauth:list-providers': { + req: z.null(), + res: z.object({ + providers: z.array(z.string()), + }), + }, + 'oauth:get-connected-providers': { + req: z.null(), + res: z.object({ + providers: z.array(z.string()), + }), + }, } as const; // ============================================================================