diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index be662f29..03253c72 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -6,6 +6,8 @@ import { isConnected, getConnectedProviders, listProviders, + getAuthStatus, + logoutRowboat, } from './oauth-handler.js'; import { watcher as watcherCore, workspace } from '@x/core'; import { workspace as workspaceShared } from '@x/shared'; @@ -220,6 +222,15 @@ export function emitOAuthEvent(event: { provider: string; success: boolean; erro } } +export function emitAuthEvent(event: { isAuthenticated: boolean; user: { email: string; name?: string } | null }): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('auth:didAuthenticate', event); + } + } +} + let runsWatcher: (() => void) | null = null; export async function startRunsWatcher(): Promise { if (runsWatcher) { @@ -344,5 +355,14 @@ export function setupIpcHandlers() { markOnboardingComplete(); return { success: true }; }, + 'auth:getStatus': async () => { + return await getAuthStatus(); + }, + 'auth:login': async () => { + return await connectProvider('rowboat'); + }, + 'auth:logout': async () => { + return await logoutRowboat(); + }, }); } \ 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 index 5b55e8b7..11caf424 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -10,10 +10,13 @@ import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js'; import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; -import { emitOAuthEvent } from './ipc.js'; +import { emitOAuthEvent, emitAuthEvent } from './ipc.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; +// Cached user info for the rowboat provider +let cachedRowboatUser: { email: string; name?: string } | null = null; + // Store active OAuth flows (state -> { codeVerifier, provider, config }) const activeFlows = new Map { + try { + const oauthRepo = getOAuthRepo(); + const connected = await oauthRepo.isConnected('rowboat'); + if (!connected) { + cachedRowboatUser = null; + return { isAuthenticated: false, user: null }; + } + + // If we have cached user info, return it + if (cachedRowboatUser) { + return { isAuthenticated: true, user: cachedRowboatUser }; + } + + // Get stored tokens to check for id_token_sub + const storedTokens = await oauthRepo.getTokens('rowboat'); + if (!storedTokens?.id_token_sub) { + // Legacy tokens without sub claim — require re-login + console.log('[OAuth] No id_token_sub in stored tokens, requiring re-login'); + cachedRowboatUser = null; + return { isAuthenticated: false, user: null }; + } + + // Try to get access token (will refresh if needed) + const accessToken = await getAccessToken('rowboat'); + if (!accessToken) { + cachedRowboatUser = null; + return { isAuthenticated: false, user: null }; + } + + // Fetch user info via OIDC discovery + try { + const config = await getProviderConfiguration('rowboat'); + cachedRowboatUser = await oauthClient.fetchUserInfo(config, accessToken, storedTokens.id_token_sub); + } catch (error) { + console.error('[OAuth] Failed to fetch user info via OIDC:', error); + cachedRowboatUser = null; + return { isAuthenticated: false, user: null }; + } + + return { isAuthenticated: true, user: cachedRowboatUser }; + } catch (error) { + console.error('[OAuth] Auth status check failed:', error); + return { isAuthenticated: false, user: null }; + } +} + +/** + * Logout from rowboat (clear tokens and cached user) + */ +export async function logoutRowboat(): Promise<{ success: boolean }> { + cachedRowboatUser = null; + return disconnectProvider('rowboat'); +} + /** * Initiate OAuth flow for a provider */ @@ -181,13 +242,18 @@ export async function connectProvider(provider: string): Promise<{ success: bool // Exchange code for tokens console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); - const tokens = await oauthClient.exchangeCodeForTokens( + const { tokens, sub } = await oauthClient.exchangeCodeForTokens( flow.config, callbackUrl, flow.codeVerifier, state ); + // Persist the subject claim for future userinfo fetches + if (sub) { + tokens.id_token_sub = sub; + } + // Save tokens console.log(`[OAuth] Token exchange successful for ${provider}`); await oauthRepo.saveTokens(provider, tokens); @@ -200,6 +266,18 @@ export async function connectProvider(provider: string): Promise<{ success: bool triggerFirefliesSync(); } + // For rowboat provider, fetch user info and emit auth event + if (provider === 'rowboat' && sub) { + try { + const userInfo = await oauthClient.fetchUserInfo(flow.config, tokens.access_token, sub); + cachedRowboatUser = userInfo; + emitAuthEvent({ isAuthenticated: true, user: userInfo }); + } catch (error) { + console.error('[OAuth] Failed to fetch user info via OIDC:', error); + emitAuthEvent({ isAuthenticated: true, user: null }); + } + } + // Emit success event to renderer emitOAuthEvent({ provider, success: true }); } catch (error) { diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7e9ceac8..e31c6240 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -50,6 +50,8 @@ import { Separator } from "@/components/ui/separator" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { OnboardingModal } from '@/components/onboarding-modal' +import { useRowboatAuth } from '@/hooks/useRowboatAuth' +import { LoginScreen } from '@/components/login-screen' type DirEntry = z.infer type RunEventType = z.infer @@ -441,6 +443,24 @@ function ChatInputWithMentions({ } function App() { + const auth = useRowboatAuth() + + if (auth.isLoading) { + return ( +
+ +
+ ) + } + + if (!auth.isAuthenticated) { + return + } + + return +} + +function AppContent({ auth }: { auth: ReturnType }) { // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) const [fileHistoryBack, setFileHistoryBack] = useState([]) @@ -1669,7 +1689,7 @@ function App() {
{/* Icon sidebar - always visible, fixed position */} - + {/* Spacer for the fixed icon sidebar */}
diff --git a/apps/x/apps/renderer/src/components/login-screen.tsx b/apps/x/apps/renderer/src/components/login-screen.tsx new file mode 100644 index 00000000..acf69609 --- /dev/null +++ b/apps/x/apps/renderer/src/components/login-screen.tsx @@ -0,0 +1,51 @@ +import { Button } from './ui/button'; +import { LoaderIcon } from 'lucide-react'; + +interface LoginScreenProps { + isLoggingIn: boolean; + error: string | null; + login: () => Promise; +} + +export function LoginScreen({ isLoggingIn, error, login }: LoginScreenProps) { + return ( +
+
+
+ Rowboat +
+

+ Sign in to your Rowboat account to continue. +

+ + {error && ( +
+ {error} +
+ )} + + + + {isLoggingIn && ( +

+ Complete sign-in in your browser, then return here. +

+ )} +
+
+ ); +} diff --git a/apps/x/apps/renderer/src/components/sidebar-icon.tsx b/apps/x/apps/renderer/src/components/sidebar-icon.tsx index 54aa0622..e45e1093 100644 --- a/apps/x/apps/renderer/src/components/sidebar-icon.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-icon.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { Brain, HelpCircle, + LogOut, MessageSquare, Plug, Settings, @@ -31,7 +32,12 @@ const navItems: NavItem[] = [ { id: "knowledge", title: "Knowledge", icon: Brain }, ] -export function SidebarIcon() { +interface SidebarIconProps { + user?: { email: string; name?: string } | null; + onLogout?: () => void; +} + +export function SidebarIcon({ user, onLogout }: SidebarIconProps = {}) { const { activeSection, setActiveSection } = useSidebarSection() return ( @@ -88,6 +94,23 @@ export function SidebarIcon() { + + {/* Sign out */} + {onLogout && ( + + + + + + {user?.email ? `Sign out (${user.email})` : 'Sign out'} + + + )}
) diff --git a/apps/x/apps/renderer/src/hooks/useRowboatAuth.ts b/apps/x/apps/renderer/src/hooks/useRowboatAuth.ts new file mode 100644 index 00000000..caa9c969 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useRowboatAuth.ts @@ -0,0 +1,97 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface RowboatAuthState { + isAuthenticated: boolean; + isLoading: boolean; + isLoggingIn: boolean; + user: { email: string; name?: string } | null; + error: string | null; + login: () => Promise; + logout: () => Promise; +} + +export function useRowboatAuth(): RowboatAuthState { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [user, setUser] = useState<{ email: string; name?: string } | null>(null); + const [error, setError] = useState(null); + + // Check auth status on mount + useEffect(() => { + async function checkStatus() { + try { + const result = await window.ipc.invoke('auth:getStatus', null); + setIsAuthenticated(result.isAuthenticated); + setUser(result.user); + } catch (err) { + console.error('Failed to check auth status:', err); + setIsAuthenticated(false); + setUser(null); + } finally { + setIsLoading(false); + } + } + checkStatus(); + }, []); + + // Listen for auth events + useEffect(() => { + const cleanup = window.ipc.on('auth:didAuthenticate', (event) => { + setIsAuthenticated(event.isAuthenticated); + setUser(event.user); + setIsLoggingIn(false); + setError(null); + }); + return cleanup; + }, []); + + // Also listen for oauth:didConnect for the rowboat provider (handles errors) + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider !== 'rowboat') return; + if (!event.success) { + setIsLoggingIn(false); + setError(event.error || 'Login failed'); + } + }); + return cleanup; + }, []); + + const login = useCallback(async () => { + try { + setIsLoggingIn(true); + setError(null); + const result = await window.ipc.invoke('auth:login', null); + if (!result.success) { + setIsLoggingIn(false); + setError(result.error || 'Failed to start login'); + } + // If success, the OAuth flow has started - wait for auth:didAuthenticate event + } catch (err) { + console.error('Login failed:', err); + setIsLoggingIn(false); + setError('Failed to start login'); + } + }, []); + + const logout = useCallback(async () => { + try { + await window.ipc.invoke('auth:logout', null); + setIsAuthenticated(false); + setUser(null); + } catch (err) { + console.error('Logout failed:', err); + } + }, []); + + return { + isAuthenticated, + isLoading, + isLoggingIn, + user, + error, + login, + logout, + }; +} diff --git a/apps/x/packages/core/src/auth/oauth-client.ts b/apps/x/packages/core/src/auth/oauth-client.ts index 613cee2e..719c8af1 100644 --- a/apps/x/packages/core/src/auth/oauth-client.ts +++ b/apps/x/packages/core/src/auth/oauth-client.ts @@ -159,13 +159,16 @@ export function buildAuthorizationUrl( state: string; } ): URL { - return client.buildAuthorizationUrl(config, { + const url = client.buildAuthorizationUrl(config, { redirect_uri: params.redirectUri, scope: params.scope, code_challenge: params.codeChallenge, code_challenge_method: 'S256', state: params.state, }); + + console.log(`[OAuth] Authorization URL: ${url}`); + return url; } /** @@ -176,7 +179,7 @@ export async function exchangeCodeForTokens( callbackUrl: URL, codeVerifier: string, expectedState: string -): Promise { +): Promise<{ tokens: OAuthTokens; sub?: string }> { console.log(`[OAuth] Exchanging authorization code for tokens...`); const response = await client.authorizationCodeGrant(config, callbackUrl, { @@ -184,8 +187,27 @@ export async function exchangeCodeForTokens( expectedState, }); + const claims = response.claims(); console.log(`[OAuth] Token exchange successful`); - return toOAuthTokens(response); + return { + tokens: toOAuthTokens(response), + sub: claims?.sub, + }; +} + +/** + * Fetch user info from the OIDC userinfo endpoint (discovered via issuer metadata) + */ +export async function fetchUserInfo( + config: client.Configuration, + accessToken: string, + expectedSubject: string +): Promise<{ email: string; name?: string }> { + const userInfo = await client.fetchUserInfo(config, accessToken, expectedSubject); + return { + email: userInfo.email ?? '', + name: userInfo.name, + }; } /** diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index edda5447..9be05314 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -77,7 +77,22 @@ const providerConfigs: ProviderConfig = { 'profile', 'email', ] - } + }, + rowboat: { + discovery: { + mode: 'issuer', + issuer: 'https://yhafoahozylbdyyyqjep.supabase.co/auth/v1', + }, + client: { + mode: 'static', + clientId: '0b8a99ec-b5b2-4ddf-8e14-69a3a1675114', + }, + scopes: [ + 'openid', + 'email', + 'profile', + ], + }, }; /** diff --git a/apps/x/packages/core/src/auth/types.ts b/apps/x/packages/core/src/auth/types.ts index 249d63b4..bd4bb319 100644 --- a/apps/x/packages/core/src/auth/types.ts +++ b/apps/x/packages/core/src/auth/types.ts @@ -9,6 +9,7 @@ export const OAuthTokens = z.object({ expires_at: z.number(), // Unix timestamp token_type: z.literal('Bearer').optional(), scopes: z.array(z.string()).optional(), // Granted scopes from OAuth response + id_token_sub: z.string().optional(), // Subject claim from ID token }); export type OAuthTokens = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index ca69646a..e85247fe 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -244,6 +244,39 @@ const ipcSchemas = { success: z.literal(true), }), }, + 'auth:getStatus': { + req: z.null(), + res: z.object({ + isAuthenticated: z.boolean(), + user: z.object({ + email: z.string(), + name: z.string().optional(), + }).nullable(), + }), + }, + 'auth:login': { + req: z.null(), + res: z.object({ + success: z.boolean(), + error: z.string().optional(), + }), + }, + 'auth:logout': { + req: z.null(), + res: z.object({ + success: z.boolean(), + }), + }, + 'auth:didAuthenticate': { + req: z.object({ + isAuthenticated: z.boolean(), + user: z.object({ + email: z.string(), + name: z.string().optional(), + }).nullable(), + }), + res: z.null(), + }, } as const; // ============================================================================