feat: Add Supabase authentication with login/signup pages

Implements complete authentication system using Supabase Auth SDK following Archon's vertical slice architecture.

Frontend Changes:
- Install @supabase/supabase-js dependency
- Create auth feature in vertical slice pattern:
  * AuthContext and Provider for global auth state
  * authService with Supabase Auth methods (signIn, signUp, signOut, etc.)
  * Auth query hooks with TanStack Query integration
  * TypeScript types for User, Session, AuthState
  * ProtectedRoute component for route guards
- Add LoginPage and SignUpPage with Tron-themed design
- Update App.tsx with AuthProvider and protected routes
- Configure Supabase client with environment variables

Backend Changes:
- Create auth_service.py for JWT token validation
- Create auth_middleware.py for protecting API routes (optional, commented by default)
- Create auth_api.py with endpoints:
  * POST /api/auth/verify - Verify JWT token
  * GET /api/auth/user - Get current user
  * GET /api/auth/health - Auth service health check
- Register auth router in main.py
- Add middleware configuration (disabled by default)

Configuration:
- Update .env.example with VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY
- Add comprehensive AUTHENTICATION_SETUP.md documentation

Features:
- Email/password authentication
- Persistent sessions with localStorage
- Auto token refresh
- Route protection with loading states
- Integration with existing TanStack Query patterns
- Optional backend middleware for API protection
- Row Level Security (RLS) ready

Architecture follows CLAUDE.md guidelines:
- Vertical slice architecture for auth feature
- TanStack Query for state management
- No backwards compatibility needed (beta)
- KISS principle
- Fail fast with detailed errors

Notes:
- Auth middleware is commented out by default to avoid breaking existing installations
- Users can enable it when ready by uncommenting in main.py
- Frontend auth works independently of backend middleware
- Comprehensive setup guide included in AUTHENTICATION_SETUP.md
This commit is contained in:
Claude 2025-11-15 04:13:17 +00:00
parent 612d5801de
commit 65348bfed0
No known key found for this signature in database
17 changed files with 1238 additions and 19 deletions

View File

@ -5,6 +5,13 @@
# https://supabase.com/dashboard/project/<your project ID>/settings/api
SUPABASE_URL=
# Frontend Supabase Configuration (for authentication)
# These are required for the frontend auth feature
VITE_SUPABASE_URL=${SUPABASE_URL}
# Get the ANON (public) key from Supabase Dashboard > Settings > API
# This is DIFFERENT from the SERVICE_ROLE key - frontend uses ANON key for client-side auth
VITE_SUPABASE_ANON_KEY=
# ⚠️ CRITICAL: You MUST use the SERVICE ROLE key, NOT the Anon key! ⚠️
#
# COMMON MISTAKE: Using the anon (public) key will cause ALL saves to fail with "permission denied"!

283
AUTHENTICATION_SETUP.md Normal file
View File

@ -0,0 +1,283 @@
# Authentication Setup Guide
This guide explains how to set up and use Supabase authentication in Archon.
## Overview
Archon now supports user authentication using Supabase Auth. This allows you to:
- Create user accounts
- Secure your data with user-specific access
- Protect routes and API endpoints
- Manage sessions and tokens
## Prerequisites
1. A Supabase project (create one at [supabase.com](https://supabase.com))
2. Node.js and Python environment set up
## Step 1: Configure Environment Variables
### Backend Configuration
In your `.env` file, ensure you have:
```bash
# Supabase Configuration
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-role-key-here
# Frontend Supabase Configuration
VITE_SUPABASE_URL=${SUPABASE_URL}
VITE_SUPABASE_ANON_KEY=your-anon-public-key-here
```
### Getting Your Keys
1. Go to your Supabase Dashboard
2. Navigate to **Settings** > **API**
3. Copy the following:
- **Project URL**`SUPABASE_URL` and `VITE_SUPABASE_URL`
- **anon (public)** key → `VITE_SUPABASE_ANON_KEY` (for frontend)
- **service_role (secret)** key → `SUPABASE_SERVICE_KEY` (for backend)
**Important:** The frontend uses the ANON key (safe for client-side), while the backend uses the SERVICE_ROLE key (server-side only).
## Step 2: Enable Email Authentication in Supabase
1. Go to **Authentication** > **Providers** in your Supabase Dashboard
2. Enable **Email** provider
3. Configure email templates if desired
4. Add allowed redirect URLs:
- Development: `http://localhost:3737/*`
- Production: Your production URL
## Step 3: Enable Authentication Middleware (Optional)
By default, the authentication middleware is **disabled** to avoid breaking existing installations. To enable it:
1. Open `python/src/server/main.py`
2. Find the section labeled "Authentication Middleware (OPTIONAL)"
3. Uncomment the two lines:
```python
from .middleware.auth_middleware import AuthMiddleware
app.add_middleware(AuthMiddleware)
```
When enabled, all API routes will require authentication except:
- `/health` - Health check
- `/docs`, `/redoc`, `/openapi.json` - API documentation
- `/api/auth/*` - Authentication endpoints
## Step 4: Start the Application
```bash
# Frontend
cd archon-ui-main
npm run dev
# Backend
cd python
uv run python -m src.server.main
```
## Step 5: Create Your First User
1. Navigate to `http://localhost:3737/signup`
2. Enter your email and password
3. You'll be automatically logged in and redirected to the dashboard
## Features
### Frontend Features
- **Login Page** (`/login`) - Sign in with email/password
- **Sign Up Page** (`/signup`) - Create a new account
- **Protected Routes** - All main routes require authentication
- **AuthContext** - Global authentication state management
- **Persistent Sessions** - Sessions saved in localStorage
### Backend Features
- **JWT Token Validation** - Verify Supabase JWT tokens
- **Auth Middleware** - Protect API routes automatically
- **User Context** - Access current user in request handlers
- **Auth Service** - Reusable authentication utilities
## Usage in Components
### Using Auth Context
```typescript
import { useAuth } from '@/features/auth/context/AuthContext';
function MyComponent() {
const { user, isAuthenticated, isLoading, signOut } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!isAuthenticated) return <div>Not logged in</div>;
return (
<div>
<p>Welcome, {user?.email}</p>
<button onClick={signOut}>Sign Out</button>
</div>
);
}
```
### Using TanStack Query Hooks
```typescript
import { useLoginMutation, useLogoutMutation } from '@/features/auth/hooks/useAuthQueries';
function LoginForm() {
const loginMutation = useLoginMutation();
const handleLogin = async (email: string, password: string) => {
try {
await loginMutation.mutateAsync({ email, password });
// Redirect or show success
} catch (error) {
// Handle error
}
};
// ...
}
```
## Backend API Usage
### Access Current User in Routes
```python
from fastapi import Request
@router.get("/api/my-endpoint")
async def my_endpoint(request: Request):
# User is automatically available if authenticated
user = request.state.user
user_id = user["id"]
user_email = user["email"]
# Your logic here
return {"user_id": user_id}
```
### Validate Token Manually
```python
from src.server.services.auth_service import auth_service
async def my_function(token: str):
user = await auth_service.verify_token(token)
return user
```
## Row Level Security (RLS)
To secure your database tables, enable RLS policies in Supabase:
### Example: Secure `sources` table
```sql
-- Enable RLS
ALTER TABLE sources ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see their own sources
CREATE POLICY "Users can view own sources"
ON sources FOR SELECT
USING (auth.uid() = user_id);
-- Policy: Users can insert their own sources
CREATE POLICY "Users can insert own sources"
ON sources FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Policy: Users can update their own sources
CREATE POLICY "Users can update own sources"
ON sources FOR UPDATE
USING (auth.uid() = user_id);
-- Policy: Users can delete their own sources
CREATE POLICY "Users can delete own sources"
ON sources FOR DELETE
USING (auth.uid() = user_id);
```
Apply similar policies to:
- `documents`
- `archon_projects`
- `archon_tasks`
- Other user-specific tables
## Troubleshooting
### "Missing Supabase environment variables"
- Ensure `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` are set in your `.env` file
- Restart the frontend dev server after adding variables
### "Authentication failed" errors
- Check that you're using the correct keys (ANON for frontend, SERVICE_ROLE for backend)
- Verify your Supabase project is active
- Check Supabase logs in the dashboard
### Users getting 401 on API calls
- Ensure auth middleware is enabled if you want to protect routes
- Check that the frontend is sending the Authorization header
- Verify the token is valid in Supabase dashboard
### "Invalid authorization header format"
- Ensure the header format is: `Authorization: Bearer <token>`
- Check that the token is being passed correctly from the frontend
## Security Best Practices
1. **Never commit `.env` files** - They contain sensitive keys
2. **Use HTTPS in production** - Required for secure authentication
3. **Enable RLS** - Protect your database with Row Level Security
4. **Rotate keys regularly** - Update Supabase keys periodically
5. **Monitor authentication logs** - Check Supabase dashboard for suspicious activity
6. **Set strong password requirements** - Configure in Supabase auth settings
## Architecture
```
Frontend (React)
├── AuthProvider (Context)
├── AuthService (Supabase SDK)
├── ProtectedRoute (Route Guard)
└── Login/Signup Pages
Backend (FastAPI)
├── AuthMiddleware (JWT Validation)
├── AuthService (Token Verification)
└── Auth API Routes
├── POST /api/auth/verify
├── GET /api/auth/user
└── GET /api/auth/health
Supabase
├── Auth (User Management)
├── Database (PostgreSQL + RLS)
└── JWT Tokens
```
## Next Steps
- Configure email templates in Supabase
- Set up OAuth providers (Google, GitHub, etc.)
- Implement password reset flow
- Add user profile management
- Configure multi-factor authentication (MFA)
## Support
For issues or questions:
- Check Supabase documentation: https://supabase.com/docs/guides/auth
- Review Archon's GitHub issues
- Check the CLAUDE.md file for development guidelines

View File

@ -17,6 +17,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/supabase-js": "^2.81.1",
"@tanstack/react-query": "^5.85.8",
"@tanstack/react-query-devtools": "^5.85.8",
"clsx": "latest",
@ -3735,6 +3736,85 @@
"integrity": "sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz",
"integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz",
"integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz",
"integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz",
"integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz",
"integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz",
"integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.81.1",
"@supabase/functions-js": "2.81.1",
"@supabase/postgrest-js": "2.81.1",
"@supabase/realtime-js": "2.81.1",
"@supabase/storage-js": "2.81.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.87.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.87.0.tgz",
@ -4053,12 +4133,17 @@
"version": "20.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.0.tgz",
"integrity": "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
@ -4098,6 +4183,15 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
@ -11236,7 +11330,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unidiff": {
@ -11962,7 +12055,6 @@
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@ -37,6 +37,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/supabase-js": "^2.81.1",
"@tanstack/react-query": "^5.85.8",
"@tanstack/react-query-devtools": "^5.85.8",
"clsx": "latest",

View File

@ -7,6 +7,8 @@ import { KnowledgeBasePage } from './pages/KnowledgeBasePage';
import { SettingsPage } from './pages/SettingsPage';
import { MCPPage } from './pages/MCPPage';
import { OnboardingPage } from './pages/OnboardingPage';
import { LoginPage } from './pages/LoginPage';
import { SignUpPage } from './pages/SignUpPage';
import { MainLayout } from './components/layout/MainLayout';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './features/ui/components/ToastProvider';
@ -18,21 +20,25 @@ import { ErrorBoundaryWithBugReport } from './components/bug-report/ErrorBoundar
import { MigrationBanner } from './components/ui/MigrationBanner';
import { serverHealthService } from './services/serverHealthService';
import { useMigrationStatus } from './hooks/useMigrationStatus';
import { AuthProvider } from './features/auth/context/AuthContext';
import { ProtectedRoute } from './features/auth/components/ProtectedRoute';
const AppRoutes = () => {
const { projectsEnabled } = useSettings();
return (
<Routes>
<Route path="/" element={<KnowledgeBasePage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/mcp" element={<MCPPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignUpPage />} />
<Route path="/" element={<ProtectedRoute><KnowledgeBasePage /></ProtectedRoute>} />
<Route path="/onboarding" element={<ProtectedRoute><OnboardingPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/mcp" element={<ProtectedRoute><MCPPage /></ProtectedRoute>} />
{projectsEnabled ? (
<>
<Route path="/projects" element={<ProjectPage />} />
<Route path="/projects/:projectId" element={<ProjectPage />} />
<Route path="/projects" element={<ProtectedRoute><ProjectPage /></ProtectedRoute>} />
<Route path="/projects/:projectId" element={<ProtectedRoute><ProjectPage /></ProtectedRoute>} />
</>
) : (
<Route path="/projects" element={<Navigate to="/" replace />} />
@ -111,15 +117,17 @@ const AppContent = () => {
export function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ToastProvider>
<TooltipProvider>
<SettingsProvider>
<AppContent />
</SettingsProvider>
</TooltipProvider>
</ToastProvider>
</ThemeProvider>
<AuthProvider>
<ThemeProvider>
<ToastProvider>
<TooltipProvider>
<SettingsProvider>
<AppContent />
</SettingsProvider>
</TooltipProvider>
</ToastProvider>
</ThemeProvider>
</AuthProvider>
{import.meta.env.VITE_SHOW_DEVTOOLS === 'true' && (
<ReactQueryDevtools initialIsOpen={false} />
)}

View File

@ -0,0 +1,28 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import type { ReactNode } from "react";
interface ProtectedRouteProps {
children: ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-cyan-500 mx-auto" />
<p className="mt-4 text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@ -0,0 +1,19 @@
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error(
"Missing Supabase environment variables. Please set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in your .env file.",
);
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
storage: window.localStorage,
},
});

View File

@ -0,0 +1,105 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { authService } from "../services/authService";
import type { AuthState, User, Session } from "../types";
interface AuthContextValue extends AuthState {
signIn: (email: string, password: string) => Promise<void>;
signUp: (email: string, password: string, metadata?: Record<string, unknown>) => Promise<void>;
signOut: () => Promise<void>;
resetPassword: (email: string) => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
authService
.getSession()
.then((session) => {
setSession(session);
setUser(session?.user ?? null);
})
.catch((error) => {
console.error("Error loading session:", error);
})
.finally(() => {
setIsLoading(false);
});
const subscription = authService.onAuthStateChange((user, session) => {
setUser(user);
setSession(session);
setIsLoading(false);
});
return () => {
subscription.unsubscribe();
};
}, []);
const signIn = async (email: string, password: string) => {
setIsLoading(true);
try {
const { user, session } = await authService.signIn({ email, password });
setUser(user);
setSession(session);
} finally {
setIsLoading(false);
}
};
const signUp = async (email: string, password: string, metadata?: Record<string, unknown>) => {
setIsLoading(true);
try {
const { user, session } = await authService.signUp({
email,
password,
metadata,
});
setUser(user);
setSession(session);
} finally {
setIsLoading(false);
}
};
const signOut = async () => {
setIsLoading(true);
try {
await authService.signOut();
setUser(null);
setSession(null);
} finally {
setIsLoading(false);
}
};
const resetPassword = async (email: string) => {
await authService.resetPassword(email);
};
const value: AuthContextValue = {
user,
session,
isLoading,
isAuthenticated: !!user,
signIn,
signUp,
signOut,
resetPassword,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@ -0,0 +1,67 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { authService } from "../services/authService";
import { STALE_TIMES } from "../../shared/config/queryPatterns";
import type { LoginCredentials, SignUpCredentials } from "../types";
export const authKeys = {
all: ["auth"] as const,
session: () => [...authKeys.all, "session"] as const,
user: () => [...authKeys.all, "user"] as const,
};
export function useAuthSession() {
return useQuery({
queryKey: authKeys.session(),
queryFn: () => authService.getSession(),
staleTime: STALE_TIMES.rare,
retry: false,
});
}
export function useCurrentUser() {
return useQuery({
queryKey: authKeys.user(),
queryFn: () => authService.getCurrentUser(),
staleTime: STALE_TIMES.rare,
retry: false,
});
}
export function useLoginMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (credentials: LoginCredentials) => authService.signIn(credentials),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: authKeys.all });
},
});
}
export function useSignUpMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (credentials: SignUpCredentials) => authService.signUp(credentials),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: authKeys.all });
},
});
}
export function useLogoutMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => authService.signOut(),
onSuccess: () => {
queryClient.clear();
},
});
}
export function useResetPasswordMutation() {
return useMutation({
mutationFn: (email: string) => authService.resetPassword(email),
});
}

View File

@ -0,0 +1,111 @@
import { supabase } from "../config/supabaseClient";
import type { LoginCredentials, SignUpCredentials, User, Session } from "../types";
export const authService = {
async signIn(credentials: LoginCredentials): Promise<{ user: User; session: Session }> {
const { data, error } = await supabase.auth.signInWithPassword({
email: credentials.email,
password: credentials.password,
});
if (error) {
throw new Error(error.message);
}
if (!data.user || !data.session) {
throw new Error("Login failed: No user or session returned");
}
return {
user: data.user,
session: data.session,
};
},
async signUp(credentials: SignUpCredentials): Promise<{ user: User; session: Session | null }> {
const { data, error } = await supabase.auth.signUp({
email: credentials.email,
password: credentials.password,
options: {
data: credentials.metadata || {},
},
});
if (error) {
throw new Error(error.message);
}
if (!data.user) {
throw new Error("Sign up failed: No user returned");
}
return {
user: data.user,
session: data.session,
};
},
async signOut(): Promise<void> {
const { error } = await supabase.auth.signOut();
if (error) {
throw new Error(error.message);
}
},
async getCurrentUser(): Promise<User | null> {
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error) {
throw new Error(error.message);
}
return user;
},
async getSession(): Promise<Session | null> {
const {
data: { session },
error,
} = await supabase.auth.getSession();
if (error) {
throw new Error(error.message);
}
return session;
},
async resetPassword(email: string): Promise<void> {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/reset-password`,
});
if (error) {
throw new Error(error.message);
}
},
async updatePassword(newPassword: string): Promise<void> {
const { error } = await supabase.auth.updateUser({
password: newPassword,
});
if (error) {
throw new Error(error.message);
}
},
onAuthStateChange(callback: (user: User | null, session: Session | null) => void) {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
callback(session?.user ?? null, session);
});
return subscription;
},
};

View File

@ -0,0 +1,30 @@
import type { User as SupabaseUser, Session as SupabaseSession } from "@supabase/supabase-js";
export type User = SupabaseUser;
export type Session = SupabaseSession;
export interface AuthState {
user: User | null;
session: Session | null;
isLoading: boolean;
isAuthenticated: boolean;
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface SignUpCredentials {
email: string;
password: string;
metadata?: {
full_name?: string;
[key: string]: unknown;
};
}
export interface AuthError {
message: string;
status?: number;
}

View File

@ -0,0 +1,93 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../features/auth/context/AuthContext";
export function LoginPage() {
const navigate = useNavigate();
const { signIn, isLoading } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
await signIn(email, password);
navigate("/");
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred during login");
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 via-gray-800 to-black">
<div className="w-full max-w-md px-6">
<div className="bg-gray-800/50 backdrop-blur-md border border-cyan-500/30 rounded-lg p-8 shadow-2xl">
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold text-cyan-400 mb-2">Archon</h1>
<p className="text-gray-400">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-500/10 border border-red-500/50 rounded-md p-3 text-red-400 text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="w-full px-4 py-2 bg-gray-900/50 border border-gray-700 rounded-md text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent disabled:opacity-50"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
className="w-full px-4 py-2 bg-gray-900/50 border border-gray-700 rounded-md text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2 px-4 bg-cyan-600 hover:bg-cyan-700 disabled:bg-cyan-800 disabled:cursor-not-allowed text-white font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-gray-900"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
<div className="mt-6 text-center text-sm">
<p className="text-gray-400">
Don't have an account?{" "}
<Link to="/signup" className="text-cyan-400 hover:text-cyan-300 font-medium">
Sign up
</Link>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,148 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../features/auth/context/AuthContext";
export function SignUpPage() {
const navigate = useNavigate();
const { signUp, isLoading } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters long");
return;
}
try {
await signUp(email, password);
setSuccess(true);
setTimeout(() => {
navigate("/");
}, 2000);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred during sign up");
}
};
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 via-gray-800 to-black">
<div className="w-full max-w-md px-6">
<div className="bg-gray-800/50 backdrop-blur-md border border-cyan-500/30 rounded-lg p-8 shadow-2xl text-center">
<div className="mb-4">
<svg
className="w-16 h-16 text-green-500 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-cyan-400 mb-2">Account Created!</h2>
<p className="text-gray-400">Redirecting to your dashboard...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 via-gray-800 to-black">
<div className="w-full max-w-md px-6">
<div className="bg-gray-800/50 backdrop-blur-md border border-cyan-500/30 rounded-lg p-8 shadow-2xl">
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold text-cyan-400 mb-2">Archon</h1>
<p className="text-gray-400">Create your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-500/10 border border-red-500/50 rounded-md p-3 text-red-400 text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="w-full px-4 py-2 bg-gray-900/50 border border-gray-700 rounded-md text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent disabled:opacity-50"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
className="w-full px-4 py-2 bg-gray-900/50 border border-gray-700 rounded-md text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent disabled:opacity-50"
placeholder="••••••••"
/>
<p className="mt-1 text-xs text-gray-500">At least 6 characters</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isLoading}
className="w-full px-4 py-2 bg-gray-900/50 border border-gray-700 rounded-md text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2 px-4 bg-cyan-600 hover:bg-cyan-700 disabled:bg-cyan-800 disabled:cursor-not-allowed text-white font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-gray-900"
>
{isLoading ? "Creating account..." : "Create account"}
</button>
</form>
<div className="mt-6 text-center text-sm">
<p className="text-gray-400">
Already have an account?{" "}
<Link to="/login" className="text-cyan-400 hover:text-cyan-300 font-medium">
Sign in
</Link>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,75 @@
"""
Authentication API endpoints.
Provides endpoints for token verification and user information.
Note: Login/signup/logout are handled client-side by Supabase Auth SDK.
"""
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from ..services.auth_service import auth_service
router = APIRouter(prefix="/api/auth", tags=["authentication"])
class TokenVerifyRequest(BaseModel):
"""Request model for token verification."""
token: str
class TokenVerifyResponse(BaseModel):
"""Response model for token verification."""
valid: bool
user: dict | None = None
class UserResponse(BaseModel):
"""Response model for user information."""
id: str
email: str
user_metadata: dict
app_metadata: dict
@router.post("/verify", response_model=TokenVerifyResponse)
async def verify_token(request: TokenVerifyRequest):
"""
Verify a JWT token and return user information.
This endpoint is public and does not require authentication.
It's used to validate tokens from the frontend.
"""
try:
user = await auth_service.verify_token(request.token)
return TokenVerifyResponse(valid=True, user=user)
except HTTPException:
return TokenVerifyResponse(valid=False, user=None)
@router.get("/user", response_model=UserResponse)
async def get_current_user(request: Request):
"""
Get information about the currently authenticated user.
Requires valid JWT token in Authorization header.
"""
if not hasattr(request.state, "user"):
raise HTTPException(status_code=401, detail="Not authenticated")
user = request.state.user
return UserResponse(
id=user["id"],
email=user["email"],
user_metadata=user.get("user_metadata", {}),
app_metadata=user.get("app_metadata", {}),
)
@router.get("/health")
async def auth_health():
"""Health check endpoint for authentication service."""
return {"status": "healthy", "service": "auth"}

View File

@ -19,6 +19,7 @@ from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from .api_routes.agent_chat_api import router as agent_chat_router
from .api_routes.auth_api import router as auth_router
from .api_routes.bug_report_api import router as bug_report_router
from .api_routes.internal_api import router as internal_router
from .api_routes.knowledge_api import router as knowledge_router
@ -160,6 +161,11 @@ app.add_middleware(
allow_headers=["*"],
)
# Authentication Middleware (OPTIONAL)
# Uncomment the lines below to enable JWT authentication on all routes except public paths
# from .middleware.auth_middleware import AuthMiddleware
# app.add_middleware(AuthMiddleware)
# Add middleware to skip logging for health checks
@app.middleware("http")
@ -179,6 +185,7 @@ async def skip_health_check_logs(request, call_next):
# Include API routers
app.include_router(auth_router)
app.include_router(settings_router)
app.include_router(mcp_router)
# app.include_router(mcp_client_router) # Removed - not part of new architecture

View File

@ -0,0 +1,48 @@
"""
Authentication middleware for protecting API routes.
"""
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from ..services.auth_service import auth_service
class AuthMiddleware(BaseHTTPMiddleware):
"""
Middleware to validate JWT tokens on protected routes.
Public routes that do NOT require authentication:
- /api/auth/* (authentication endpoints)
- /health (health check)
- /docs, /redoc, /openapi.json (API documentation)
"""
PUBLIC_PATHS = {
"/health",
"/docs",
"/redoc",
"/openapi.json",
}
PUBLIC_PREFIXES = [
"/api/auth/",
]
async def dispatch(self, request: Request, call_next):
"""Process request and validate authentication if required."""
path = request.url.path
if path in self.PUBLIC_PATHS or any(path.startswith(prefix) for prefix in self.PUBLIC_PREFIXES):
return await call_next(request)
try:
user = await auth_service.require_auth(request)
request.state.user = user
except HTTPException as e:
return JSONResponse(status_code=e.status_code, content={"detail": e.detail})
except Exception as e:
return JSONResponse(status_code=500, content={"detail": f"Authentication error: {str(e)}"})
return await call_next(request)

View File

@ -0,0 +1,97 @@
"""
Authentication service for validating JWT tokens and managing user sessions.
"""
from typing import Optional
from fastapi import HTTPException, Request
from supabase import Client
from ..utils import get_supabase_client
class AuthService:
"""Service for handling authentication operations."""
def __init__(self):
self.supabase: Client = get_supabase_client()
async def verify_token(self, token: str) -> dict:
"""
Verify a JWT token and return the user information.
Args:
token: JWT token from Authorization header
Returns:
dict: User information from Supabase
Raises:
HTTPException: If token is invalid or expired
"""
try:
response = self.supabase.auth.get_user(token)
if not response.user:
raise HTTPException(status_code=401, detail="Invalid authentication token")
return {
"id": response.user.id,
"email": response.user.email,
"user_metadata": response.user.user_metadata,
"app_metadata": response.user.app_metadata,
}
except Exception as e:
raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}")
async def get_user_by_id(self, user_id: str) -> Optional[dict]:
"""
Get user information by user ID.
Args:
user_id: User ID from Supabase
Returns:
dict: User information or None if not found
"""
try:
response = self.supabase.auth.admin.get_user_by_id(user_id)
if not response.user:
return None
return {
"id": response.user.id,
"email": response.user.email,
"user_metadata": response.user.user_metadata,
"app_metadata": response.user.app_metadata,
}
except Exception:
return None
async def require_auth(self, request: Request) -> dict:
"""
Extract and verify authentication from request.
Args:
request: FastAPI request object
Returns:
dict: User information
Raises:
HTTPException: If authentication fails
"""
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(status_code=401, detail="Missing authorization header")
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(status_code=401, detail="Invalid authorization header format")
token = parts[1]
return await self.verify_token(token)
auth_service = AuthService()