diff --git a/.env.example b/.env.example index 363b424..4c2f646 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,13 @@ # https://supabase.com/dashboard/project//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"! diff --git a/AUTHENTICATION_SETUP.md b/AUTHENTICATION_SETUP.md new file mode 100644 index 0000000..2c02785 --- /dev/null +++ b/AUTHENTICATION_SETUP.md @@ -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
Loading...
; + if (!isAuthenticated) return
Not logged in
; + + return ( +
+

Welcome, {user?.email}

+ +
+ ); +} +``` + +### 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 ` +- 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 diff --git a/archon-ui-main/package-lock.json b/archon-ui-main/package-lock.json index a665375..cae844f 100644 --- a/archon-ui-main/package-lock.json +++ b/archon-ui-main/package-lock.json @@ -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" diff --git a/archon-ui-main/package.json b/archon-ui-main/package.json index 31c0757..5b6e2fe 100644 --- a/archon-ui-main/package.json +++ b/archon-ui-main/package.json @@ -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", diff --git a/archon-ui-main/src/App.tsx b/archon-ui-main/src/App.tsx index ea2539c..530da35 100644 --- a/archon-ui-main/src/App.tsx +++ b/archon-ui-main/src/App.tsx @@ -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 ( - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> {projectsEnabled ? ( <> - } /> - } /> + } /> + } /> ) : ( } /> @@ -111,15 +117,17 @@ const AppContent = () => { export function App() { return ( - - - - - - - - - + + + + + + + + + + + {import.meta.env.VITE_SHOW_DEVTOOLS === 'true' && ( )} diff --git a/archon-ui-main/src/features/auth/components/ProtectedRoute.tsx b/archon-ui-main/src/features/auth/components/ProtectedRoute.tsx new file mode 100644 index 0000000..6bc5ae4 --- /dev/null +++ b/archon-ui-main/src/features/auth/components/ProtectedRoute.tsx @@ -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 ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/archon-ui-main/src/features/auth/config/supabaseClient.ts b/archon-ui-main/src/features/auth/config/supabaseClient.ts new file mode 100644 index 0000000..1667e0a --- /dev/null +++ b/archon-ui-main/src/features/auth/config/supabaseClient.ts @@ -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, + }, +}); diff --git a/archon-ui-main/src/features/auth/context/AuthContext.tsx b/archon-ui-main/src/features/auth/context/AuthContext.tsx new file mode 100644 index 0000000..1a58c54 --- /dev/null +++ b/archon-ui-main/src/features/auth/context/AuthContext.tsx @@ -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; + signUp: (email: string, password: string, metadata?: Record) => Promise; + signOut: () => Promise; + resetPassword: (email: string) => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [session, setSession] = useState(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) => { + 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 {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/archon-ui-main/src/features/auth/hooks/useAuthQueries.ts b/archon-ui-main/src/features/auth/hooks/useAuthQueries.ts new file mode 100644 index 0000000..391c64d --- /dev/null +++ b/archon-ui-main/src/features/auth/hooks/useAuthQueries.ts @@ -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), + }); +} diff --git a/archon-ui-main/src/features/auth/services/authService.ts b/archon-ui-main/src/features/auth/services/authService.ts new file mode 100644 index 0000000..a95b74b --- /dev/null +++ b/archon-ui-main/src/features/auth/services/authService.ts @@ -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 { + const { error } = await supabase.auth.signOut(); + + if (error) { + throw new Error(error.message); + } + }, + + async getCurrentUser(): Promise { + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error) { + throw new Error(error.message); + } + + return user; + }, + + async getSession(): Promise { + const { + data: { session }, + error, + } = await supabase.auth.getSession(); + + if (error) { + throw new Error(error.message); + } + + return session; + }, + + async resetPassword(email: string): Promise { + 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 { + 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; + }, +}; diff --git a/archon-ui-main/src/features/auth/types/index.ts b/archon-ui-main/src/features/auth/types/index.ts new file mode 100644 index 0000000..35b9187 --- /dev/null +++ b/archon-ui-main/src/features/auth/types/index.ts @@ -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; +} diff --git a/archon-ui-main/src/pages/LoginPage.tsx b/archon-ui-main/src/pages/LoginPage.tsx new file mode 100644 index 0000000..f77e2a7 --- /dev/null +++ b/archon-ui-main/src/pages/LoginPage.tsx @@ -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(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 ( +
+
+
+
+

Archon

+

Sign in to your account

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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" + /> +
+ +
+ + 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="••••••••" + /> +
+ + +
+ +
+

+ Don't have an account?{" "} + + Sign up + +

+
+
+
+
+ ); +} diff --git a/archon-ui-main/src/pages/SignUpPage.tsx b/archon-ui-main/src/pages/SignUpPage.tsx new file mode 100644 index 0000000..82d963e --- /dev/null +++ b/archon-ui-main/src/pages/SignUpPage.tsx @@ -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(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 ( +
+
+
+
+ + + +
+

Account Created!

+

Redirecting to your dashboard...

+
+
+
+ ); + } + + return ( +
+
+
+
+

Archon

+

Create your account

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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" + /> +
+ +
+ + 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="••••••••" + /> +

At least 6 characters

+
+ +
+ + 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="••••••••" + /> +
+ + +
+ +
+

+ Already have an account?{" "} + + Sign in + +

+
+
+
+
+ ); +} diff --git a/python/src/server/api_routes/auth_api.py b/python/src/server/api_routes/auth_api.py new file mode 100644 index 0000000..a0e2bca --- /dev/null +++ b/python/src/server/api_routes/auth_api.py @@ -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"} diff --git a/python/src/server/main.py b/python/src/server/main.py index 19456e0..e94cac0 100644 --- a/python/src/server/main.py +++ b/python/src/server/main.py @@ -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 diff --git a/python/src/server/middleware/auth_middleware.py b/python/src/server/middleware/auth_middleware.py new file mode 100644 index 0000000..19853e4 --- /dev/null +++ b/python/src/server/middleware/auth_middleware.py @@ -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) diff --git a/python/src/server/services/auth_service.py b/python/src/server/services/auth_service.py new file mode 100644 index 0000000..6c5c314 --- /dev/null +++ b/python/src/server/services/auth_service.py @@ -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()