From bbe82c124d18d855a5341c571fe9db5c20370f63 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:25:33 +0530 Subject: [PATCH] feat: integrate Supabase OAuth with OIDC discovery for authentication Add rowboat auth flow using Supabase as the OIDC provider. User info is fetched via the standard OIDC userinfo endpoint (discovered from issuer metadata) instead of a hard-coded Supabase URL. Includes login screen, auth state hook, IPC handlers, logout button, and id_token_sub persistence for userinfo fetches across app restarts. Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/main/src/ipc.ts | 20 ++++ apps/x/apps/main/src/oauth-handler.ts | 82 +++++++++++++++- apps/x/apps/renderer/src/App.tsx | 22 ++++- .../renderer/src/components/login-screen.tsx | 51 ++++++++++ .../renderer/src/components/sidebar-icon.tsx | 25 ++++- .../apps/renderer/src/hooks/useRowboatAuth.ts | 97 +++++++++++++++++++ apps/x/packages/core/src/auth/oauth-client.ts | 28 +++++- apps/x/packages/core/src/auth/providers.ts | 17 +++- apps/x/packages/core/src/auth/types.ts | 1 + apps/x/packages/shared/src/ipc.ts | 33 +++++++ 10 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/login-screen.tsx create mode 100644 apps/x/apps/renderer/src/hooks/useRowboatAuth.ts 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; // ============================================================================