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 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-02-03 07:25:33 +05:30
parent 84c101fa21
commit bbe82c124d
10 changed files with 368 additions and 8 deletions

View file

@ -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<typeof workspace.DirEntry>
type RunEventType = z.infer<typeof RunEvent>
@ -441,6 +443,24 @@ function ChatInputWithMentions({
}
function App() {
const auth = useRowboatAuth()
if (auth.isLoading) {
return (
<div className="flex h-svh w-full items-center justify-center bg-background">
<LoaderIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
if (!auth.isAuthenticated) {
return <LoginScreen isLoggingIn={auth.isLoggingIn} error={auth.error} login={auth.login} />
}
return <AppContent auth={auth} />
}
function AppContent({ auth }: { auth: ReturnType<typeof useRowboatAuth> }) {
// File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileHistoryBack, setFileHistoryBack] = useState<string[]>([])
@ -1669,7 +1689,7 @@ function App() {
<SidebarSectionProvider defaultSection="tasks" onSectionChange={handleSectionChange}>
<div className="flex h-svh w-full">
{/* Icon sidebar - always visible, fixed position */}
<SidebarIcon />
<SidebarIcon user={auth.user} onLogout={auth.logout} />
{/* Spacer for the fixed icon sidebar */}
<div className="w-14 shrink-0" />

View file

@ -0,0 +1,51 @@
import { Button } from './ui/button';
import { LoaderIcon } from 'lucide-react';
interface LoginScreenProps {
isLoggingIn: boolean;
error: string | null;
login: () => Promise<void>;
}
export function LoginScreen({ isLoggingIn, error, login }: LoginScreenProps) {
return (
<div className="flex h-svh w-full items-center justify-center bg-background">
<div className="flex flex-col items-center gap-6 max-w-sm text-center px-4">
<div className="text-4xl font-semibold tracking-tight text-foreground/80">
Rowboat
</div>
<p className="text-sm text-muted-foreground">
Sign in to your Rowboat account to continue.
</p>
{error && (
<div className="w-full rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<Button
onClick={login}
disabled={isLoggingIn}
className="w-full"
size="lg"
>
{isLoggingIn ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
Waiting for browser...
</>
) : (
'Sign in to Rowboat'
)}
</Button>
{isLoggingIn && (
<p className="text-xs text-muted-foreground">
Complete sign-in in your browser, then return here.
</p>
)}
</div>
</div>
);
}

View file

@ -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() {
<HelpCircle className="size-5" />
</button>
</HelpPopover>
{/* Sign out */}
{onLogout && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onLogout}
className="flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<LogOut className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{user?.email ? `Sign out (${user.email})` : 'Sign out'}
</TooltipContent>
</Tooltip>
)}
</nav>
</div>
)

View file

@ -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<void>;
logout: () => Promise<void>;
}
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<string | null>(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,
};
}