mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-27 17:36:25 +02:00
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:
parent
84c101fa21
commit
bbe82c124d
10 changed files with 368 additions and 8 deletions
|
|
@ -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<void> {
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string, {
|
||||
codeVerifier: string;
|
||||
|
|
@ -129,6 +132,64 @@ async function getProviderConfiguration(provider: string): Promise<Configuration
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication status for the rowboat provider
|
||||
*/
|
||||
export async function getAuthStatus(): Promise<{ isAuthenticated: boolean; user: { email: string; name?: string } | null }> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
51
apps/x/apps/renderer/src/components/login-screen.tsx
Normal file
51
apps/x/apps/renderer/src/components/login-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
97
apps/x/apps/renderer/src/hooks/useRowboatAuth.ts
Normal file
97
apps/x/apps/renderer/src/hooks/useRowboatAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue