mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
add connected accounts (oauth) feature
This commit is contained in:
parent
47ab50bfe7
commit
dfe940d0ba
17 changed files with 1084 additions and 24 deletions
119
apps/x/apps/main/src/auth-server.ts
Normal file
119
apps/x/apps/main/src/auth-server.ts
Normal file
|
|
@ -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<AuthServerResult> {
|
||||||
|
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(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OAuth Error</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||||
|
.error { color: #d32f2f; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 class="error">Authorization Failed</h1>
|
||||||
|
<p>Error: ${error}</p>
|
||||||
|
<p>You can close this window.</p>
|
||||||
|
<script>setTimeout(() => window.close(), 3000);</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code && state) {
|
||||||
|
onCallback(code, state);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authorization Successful</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||||
|
.success { color: #2e7d32; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 class="success">Authorization Successful</h1>
|
||||||
|
<p>You can close this window.</p>
|
||||||
|
<script>setTimeout(() => window.close(), 2000);</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OAuth Error</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||||
|
.error { color: #d32f2f; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 class="error">Invalid Request</h1>
|
||||||
|
<p>Missing code or state parameter.</p>
|
||||||
|
<p>You can close this window.</p>
|
||||||
|
<script>setTimeout(() => window.close(), 3000);</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { ipcMain, BrowserWindow } from 'electron';
|
import { ipcMain, BrowserWindow } from 'electron';
|
||||||
import { ipc } from '@x/shared';
|
import { ipc } from '@x/shared';
|
||||||
|
import {
|
||||||
|
connectProvider,
|
||||||
|
disconnectProvider,
|
||||||
|
isConnected,
|
||||||
|
getConnectedProviders,
|
||||||
|
listProviders,
|
||||||
|
} from './oauth-handler.js';
|
||||||
import { watcher as watcherCore, workspace } from '@x/core';
|
import { watcher as watcherCore, workspace } from '@x/core';
|
||||||
import { workspace as workspaceShared } from '@x/shared';
|
import { workspace as workspaceShared } from '@x/shared';
|
||||||
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
|
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
|
||||||
|
|
@ -278,5 +285,20 @@ export function setupIpcHandlers() {
|
||||||
await runsCore.stop(args.runId);
|
await runsCore.stop(args.runId);
|
||||||
return { success: true };
|
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();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
197
apps/x/apps/main/src/oauth-handler.ts
Normal file
197
apps/x/apps/main/src/oauth-handler.ts
Normal file
|
|
@ -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<string, { codeVerifier: string; provider: string }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<IOAuthRepo>('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<string | null> {
|
||||||
|
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() };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -201,6 +201,9 @@ function buildTree(entries: DirEntry[]): TreeNode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
// Sidebar view state
|
||||||
|
const [activeSidebarView, setActiveSidebarView] = useState<'files' | 'accounts'>('files')
|
||||||
|
|
||||||
// File browser state
|
// File browser state
|
||||||
const [tree, setTree] = useState<TreeNode[]>([])
|
const [tree, setTree] = useState<TreeNode[]>([])
|
||||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||||
|
|
@ -577,6 +580,8 @@ function App() {
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
expandedPaths={expandedPaths}
|
expandedPaths={expandedPaths}
|
||||||
onSelectFile={toggleExpand}
|
onSelectFile={toggleExpand}
|
||||||
|
activeView={activeSidebarView}
|
||||||
|
onViewChange={setActiveSidebarView}
|
||||||
/>
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="bg-background sticky top-0 z-20 flex shrink-0 items-center gap-2 border-b p-4 shadow-sm">
|
<header className="bg-background sticky top-0 z-20 flex shrink-0 items-center gap-2 border-b p-4 shadow-sm">
|
||||||
|
|
|
||||||
|
|
@ -666,7 +666,7 @@ export const PromptInput = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
|
|
||||||
[usingProvider]
|
[usingProvider]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { ChevronRight, File, Folder } from "lucide-react"
|
import { ChevronRight, File, Folder, Plug } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import { ConnectedAccountsSidebar } from "@/components/connected-accounts-sidebar"
|
||||||
|
|
||||||
type TreeNode = {
|
type TreeNode = {
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -27,11 +28,15 @@ type TreeNode = {
|
||||||
children?: TreeNode[]
|
children?: TreeNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SidebarView = 'files' | 'accounts'
|
||||||
|
|
||||||
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||||
tree: TreeNode[]
|
tree: TreeNode[]
|
||||||
selectedPath: string | null
|
selectedPath: string | null
|
||||||
expandedPaths: Set<string>
|
expandedPaths: Set<string>
|
||||||
onSelectFile: (path: string, kind: 'file' | 'dir') => void
|
onSelectFile: (path: string, kind: 'file' | 'dir') => void
|
||||||
|
activeView: SidebarView
|
||||||
|
onViewChange: (view: SidebarView) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({
|
export function AppSidebar({
|
||||||
|
|
@ -39,6 +44,8 @@ export function AppSidebar({
|
||||||
selectedPath,
|
selectedPath,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
|
activeView,
|
||||||
|
onViewChange,
|
||||||
...props
|
...props
|
||||||
}: AppSidebarProps) {
|
}: AppSidebarProps) {
|
||||||
const { setOpen } = useSidebar()
|
const { setOpen } = useSidebar()
|
||||||
|
|
@ -64,15 +71,30 @@ export function AppSidebar({
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip="Files"
|
tooltip="Files"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
onViewChange('files')
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}}
|
}}
|
||||||
isActive={true}
|
isActive={activeView === 'files'}
|
||||||
className="px-2.5 md:px-2"
|
className="px-2.5 md:px-2"
|
||||||
>
|
>
|
||||||
<File />
|
<File />
|
||||||
<span>Files</span>
|
<span>Files</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip="Connected Accounts"
|
||||||
|
onClick={() => {
|
||||||
|
onViewChange('accounts')
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
isActive={activeView === 'accounts'}
|
||||||
|
className="px-2.5 md:px-2"
|
||||||
|
>
|
||||||
|
<Plug />
|
||||||
|
<span>Connected Accounts</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
@ -81,26 +103,30 @@ export function AppSidebar({
|
||||||
|
|
||||||
{/* This is the second sidebar */}
|
{/* This is the second sidebar */}
|
||||||
{/* We disable collapsible and let it fill remaining space */}
|
{/* We disable collapsible and let it fill remaining space */}
|
||||||
<Sidebar collapsible="none" className="hidden flex-1 md:flex">
|
{activeView === 'files' ? (
|
||||||
<SidebarContent>
|
<Sidebar collapsible="none" className="hidden flex-1 md:flex">
|
||||||
<SidebarGroup>
|
<SidebarContent>
|
||||||
<SidebarGroupLabel>Files</SidebarGroupLabel>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupLabel>Files</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarGroupContent>
|
||||||
{tree.map((item, index) => (
|
<SidebarMenu>
|
||||||
<Tree
|
{tree.map((item, index) => (
|
||||||
key={index}
|
<Tree
|
||||||
item={item}
|
key={index}
|
||||||
selectedPath={selectedPath}
|
item={item}
|
||||||
expandedPaths={expandedPaths}
|
selectedPath={selectedPath}
|
||||||
onSelect={onSelectFile}
|
expandedPaths={expandedPaths}
|
||||||
/>
|
onSelect={onSelectFile}
|
||||||
))}
|
/>
|
||||||
</SidebarMenu>
|
))}
|
||||||
</SidebarGroupContent>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroupContent>
|
||||||
</SidebarContent>
|
</SidebarGroup>
|
||||||
</Sidebar>
|
</SidebarContent>
|
||||||
|
</Sidebar>
|
||||||
|
) : (
|
||||||
|
<ConnectedAccountsSidebar />
|
||||||
|
)}
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<typeof Sidebar>
|
||||||
|
|
||||||
|
export function ConnectedAccountsSidebar({ ...props }: ConnectedAccountsSidebarProps) {
|
||||||
|
const { providers, isLoading: providersLoading } = useAvailableProviders()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="none" className="hidden flex-1 md:flex" {...props}>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Connected Accounts</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{providersLoading ? (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton disabled>
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
<span>Loading...</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton disabled>
|
||||||
|
<span className="text-muted-foreground">No providers available</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
) : (
|
||||||
|
providers.map((provider) => (
|
||||||
|
<ProviderItem key={provider} provider={provider} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderItem({ provider }: { provider: string }) {
|
||||||
|
const { isConnected, isLoading, isConnecting, connect, disconnect } = useOAuth(provider)
|
||||||
|
const providerDisplayName = provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<div className="flex items-center justify-between w-full gap-2 px-2 py-1.5">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<Plug className="size-4 shrink-0" />
|
||||||
|
<span className="truncate">{providerDisplayName}</span>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant={isConnected ? "default" : "outline"}
|
||||||
|
className="shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
{isConnected ? "Connected" : "Not Connected"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
{isConnected ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={disconnect}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={connect}
|
||||||
|
disabled={isConnecting || isLoading}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Connect"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
128
apps/x/apps/renderer/src/hooks/useOAuth.ts
Normal file
128
apps/x/apps/renderer/src/hooks/useOAuth.ts
Normal file
|
|
@ -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<boolean>(false);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isConnecting, setIsConnecting] = useState<boolean>(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<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(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<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(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 };
|
||||||
|
}
|
||||||
|
|
||||||
59
apps/x/apps/renderer/src/lib/toast.ts
Normal file
59
apps/x/apps/renderer/src/lib/toast.ts
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
150
apps/x/packages/core/src/auth/oauth.ts
Normal file
150
apps/x/packages/core/src/auth/oauth.ts
Normal file
|
|
@ -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<OAuthTokens> {
|
||||||
|
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<OAuthTokens> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
34
apps/x/packages/core/src/auth/pkce.ts
Normal file
34
apps/x/packages/core/src/auth/pkce.ts
Normal file
|
|
@ -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, '');
|
||||||
|
}
|
||||||
|
|
||||||
52
apps/x/packages/core/src/auth/providers.ts
Normal file
52
apps/x/packages/core/src/auth/providers.ts
Normal file
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
101
apps/x/packages/core/src/auth/repo.ts
Normal file
101
apps/x/packages/core/src/auth/repo.ts
Normal file
|
|
@ -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<OAuthTokens | null>;
|
||||||
|
saveTokens(provider: string, tokens: OAuthTokens): Promise<void>;
|
||||||
|
clearTokens(provider: string): Promise<void>;
|
||||||
|
isConnected(provider: string): Promise<boolean>;
|
||||||
|
getConnectedProviders(): Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<OAuthStorage> {
|
||||||
|
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<void> {
|
||||||
|
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTokens(provider: string): Promise<OAuthTokens | null> {
|
||||||
|
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<void> {
|
||||||
|
const config = await this.readConfig();
|
||||||
|
config[provider] = tokens;
|
||||||
|
await this.writeConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearTokens(provider: string): Promise<void> {
|
||||||
|
const config = await this.readConfig();
|
||||||
|
delete config[provider];
|
||||||
|
await this.writeConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isConnected(provider: string): Promise<boolean> {
|
||||||
|
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<string[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { IMessageQueue, InMemoryMessageQueue } from "../application/lib/message-
|
||||||
import { IBus, InMemoryBus } from "../application/lib/bus.js";
|
import { IBus, InMemoryBus } from "../application/lib/bus.js";
|
||||||
import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js";
|
import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js";
|
||||||
import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
|
import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
|
||||||
|
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
|
||||||
|
|
||||||
const container = createContainer({
|
const container = createContainer({
|
||||||
injectionMode: InjectionMode.PROXY,
|
injectionMode: InjectionMode.PROXY,
|
||||||
|
|
@ -25,6 +26,7 @@ container.register({
|
||||||
modelConfigRepo: asClass<IModelConfigRepo>(FSModelConfigRepo).singleton(),
|
modelConfigRepo: asClass<IModelConfigRepo>(FSModelConfigRepo).singleton(),
|
||||||
agentsRepo: asClass<IAgentsRepo>(FSAgentsRepo).singleton(),
|
agentsRepo: asClass<IAgentsRepo>(FSAgentsRepo).singleton(),
|
||||||
runsRepo: asClass<IRunsRepo>(FSRunsRepo).singleton(),
|
runsRepo: asClass<IRunsRepo>(FSRunsRepo).singleton(),
|
||||||
|
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default container;
|
export default container;
|
||||||
15
apps/x/packages/shared/src/auth.ts
Normal file
15
apps/x/packages/shared/src/auth.ts
Normal file
|
|
@ -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<typeof OAuthTokens>;
|
||||||
|
|
||||||
|
|
@ -3,4 +3,5 @@ import { PrefixLogger } from './prefix-logger.js';
|
||||||
export * as ipc from './ipc.js';
|
export * as ipc from './ipc.js';
|
||||||
export * as workspace from './workspace.js';
|
export * as workspace from './workspace.js';
|
||||||
export * as mcp from './mcp.js';
|
export * as mcp from './mcp.js';
|
||||||
|
export * as auth from './auth.js';
|
||||||
export { PrefixLogger };
|
export { PrefixLogger };
|
||||||
|
|
@ -159,7 +159,44 @@ const ipcSchemas = {
|
||||||
'runs:events': {
|
'runs:events': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: 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;
|
} as const;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue