Skip to content

Migrate Authentication from Better-Auth to fastapi-users #162

Description

@frankbria

Migrate Authentication from Better-Auth to fastapi-users

Summary

Replace the current Better-Auth (JavaScript) authentication setup with fastapi-users (Python) so that authentication is owned entirely by the FastAPI backend. This eliminates the architectural mismatch where a JavaScript auth library tries to sync with a Python backend.

Priority

P0 - Blocker — Required for multi-tenant user scoping. All database entities depend on user_id.

Problem Statement

Current State

  • Frontend: Next.js with Better-Auth installed (cookies-only mode)
  • Backend: FastAPI (Python) — the "real" application
  • Database: SQLite (shared between both, but with schema conflicts)
  • Failed Integration: Drizzle ORM was installed to try to bridge JS ↔ Python, but this doesn't work

Why It's Broken

Better-Auth is designed for JavaScript-only stacks. It expects to:

  1. Own and manage user/session tables with its specific schema
  2. Run in a Node.js environment
  3. Handle both auth logic AND database operations in JavaScript

This creates an impedance mismatch when a Python backend also needs to:

  • Validate authentication state
  • Access user IDs for multi-tenant data scoping
  • Manage its own database models

The result is confusion about:

  • Table names (user vs users)
  • ID types (string UUIDs vs integers)
  • Password handling (who hashes, who validates)
  • Two ORMs (Drizzle + SQLAlchemy) fighting over the same SQLite file

Target State

Python/FastAPI owns authentication completely. Next.js becomes a thin API client with no auth logic.

┌───────────────────────────────────────────────────────────┐
│  Next.js Frontend (UI only, no auth logic)                │
│  - Calls /api/auth/login, /api/auth/register on backend   │
│  - Stores JWT in memory or httpOnly cookie                │
│  - Sends Authorization: Bearer <token> on API calls       │
└───────────────────────────────────────────────────────────┘
                           │
                           ▼
┌───────────────────────────────────────────────────────────┐
│  FastAPI Backend (owns everything)                        │
│  ┌─────────────────────────────────────────────────────┐ │
│  │  fastapi-users                                      │ │
│  │  - /auth/register, /auth/login, /auth/logout        │ │
│  │  - Password hashing (bcrypt)                        │ │
│  │  - JWT token generation & validation                │ │
│  │  - Single source of truth for users table           │ │
│  └─────────────────────────────────────────────────────┘ │
│  ┌─────────────────────────────────────────────────────┐ │
│  │  SQLite - all tables owned by Python/SQLAlchemy     │ │
│  │  - users (UUID primary key)                         │ │
│  │  - All business entities with user_id FK            │ │
│  └─────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘

Implementation Tasks

Phase 1: Remove Better-Auth from Frontend

1.1 Remove Better-Auth dependencies

cd web-ui
npm uninstall better-auth @better-auth/client drizzle-orm drizzle-kit

1.2 Delete Better-Auth configuration files

Remove these files/directories if they exist:

  • web-ui/lib/auth.ts (Better-Auth server config)
  • web-ui/lib/auth-client.ts (Better-Auth client)
  • web-ui/app/api/auth/[...all]/route.ts (Better-Auth API routes)
  • web-ui/drizzle/ (Drizzle migrations/schema)
  • web-ui/drizzle.config.ts
  • Any *.db files created by Better-Auth in web-ui

1.3 Remove Better-Auth environment variables

Clean up .env and .env.local:

  • BETTER_AUTH_SECRET
  • BETTER_AUTH_URL
  • Any Drizzle-related env vars

Phase 2: Install fastapi-users in Backend

2.1 Add dependencies

In pyproject.toml, add:

[project.dependencies]
# ... existing deps ...
fastapi-users = { extras = ["sqlalchemy"], version = "^13.0.0" }
aiosqlite = "^0.20.0"
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
passlib = { extras = ["bcrypt"], version = "^1.7.4" }

Then run:

uv sync

2.2 Create auth module structure

codeframe/
├── auth/
│   ├── __init__.py
│   ├── models.py      # User SQLAlchemy model
│   ├── schemas.py     # Pydantic schemas (UserRead, UserCreate, UserUpdate)
│   ├── users.py       # fastapi-users configuration
│   ├── router.py      # Auth route mounting
│   └── dependencies.py # current_active_user dependency

Phase 3: Implement Auth Models

3.1 User model (codeframe/auth/models.py)

"""User model for authentication."""
import uuid
from typing import Optional

from fastapi_users.db import SQLAlchemyBaseUserTableUUID
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column

from codeframe.db import Base  # Import existing Base


class User(SQLAlchemyBaseUserTableUUID, Base):
    """User model with multi-tenant support."""
    
    __tablename__ = "users"
    
    # Additional fields for CodeFRAME
    display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
    tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(nullable=True)
    
    # Relationships to existing models can be added here
    # projects: Mapped[list["Project"]] = relationship(back_populates="owner")

3.2 Pydantic schemas (codeframe/auth/schemas.py)

"""Pydantic schemas for user operations."""
import uuid
from typing import Optional

from fastapi_users import schemas


class UserRead(schemas.BaseUser[uuid.UUID]):
    """Schema for reading user data."""
    display_name: Optional[str] = None
    tenant_id: Optional[uuid.UUID] = None


class UserCreate(schemas.BaseUserCreate):
    """Schema for creating users."""
    display_name: Optional[str] = None


class UserUpdate(schemas.BaseUserUpdate):
    """Schema for updating users."""
    display_name: Optional[str] = None

Phase 4: Configure fastapi-users

4.1 User manager and auth backends (codeframe/auth/users.py)

"""fastapi-users configuration."""
import os
import uuid
from typing import Optional

from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
from fastapi_users.authentication import (
    AuthenticationBackend,
    BearerTransport,
    JWTStrategy,
)
from fastapi_users.db import SQLAlchemyUserDatabase
from sqlalchemy.ext.asyncio import AsyncSession

from codeframe.auth.models import User
from codeframe.db import get_async_session  # You may need to create this

SECRET = os.getenv("AUTH_SECRET", "CHANGE-ME-IN-PRODUCTION-USE-SECRETS")
JWT_LIFETIME_SECONDS = 60 * 60 * 24 * 7  # 7 days


class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
    """Custom user manager for CodeFRAME."""
    
    reset_password_token_secret = SECRET
    verification_token_secret = SECRET

    async def on_after_register(self, user: User, request: Optional[Request] = None):
        """Called after successful registration."""
        print(f"User {user.id} registered.")

    async def on_after_login(
        self, user: User, request: Optional[Request] = None, response=None
    ):
        """Called after successful login."""
        print(f"User {user.id} logged in.")


async def get_user_db(session: AsyncSession = Depends(get_async_session)):
    """Dependency for user database adapter."""
    yield SQLAlchemyUserDatabase(session, User)


async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
    """Dependency for user manager."""
    yield UserManager(user_db)


# JWT Bearer token transport
bearer_transport = BearerTransport(tokenUrl="auth/login")


def get_jwt_strategy() -> JWTStrategy:
    """JWT strategy for authentication."""
    return JWTStrategy(secret=SECRET, lifetime_seconds=JWT_LIFETIME_SECONDS)


auth_backend = AuthenticationBackend(
    name="jwt",
    transport=bearer_transport,
    get_strategy=get_jwt_strategy,
)

# FastAPIUsers instance
fastapi_users = FastAPIUsers[User, uuid.UUID](
    get_user_manager,
    [auth_backend],
)

# Dependency for protected routes
current_active_user = fastapi_users.current_user(active=True)
current_superuser = fastapi_users.current_user(active=True, superuser=True)

4.2 Mount auth routes (codeframe/auth/router.py)

"""Auth router configuration."""
from fastapi import APIRouter

from codeframe.auth.schemas import UserCreate, UserRead, UserUpdate
from codeframe.auth.users import auth_backend, fastapi_users

router = APIRouter()

# Authentication routes (login, logout)
router.include_router(
    fastapi_users.get_auth_router(auth_backend),
    prefix="/auth",
    tags=["auth"],
)

# Registration route
router.include_router(
    fastapi_users.get_register_router(UserRead, UserCreate),
    prefix="/auth",
    tags=["auth"],
)

# User management routes (get me, update me)
router.include_router(
    fastapi_users.get_users_router(UserRead, UserUpdate),
    prefix="/users",
    tags=["users"],
)

# Optional: Reset password, verify email
# router.include_router(
#     fastapi_users.get_reset_password_router(),
#     prefix="/auth",
#     tags=["auth"],
# )
# router.include_router(
#     fastapi_users.get_verify_router(UserRead),
#     prefix="/auth",
#     tags=["auth"],
# )

Phase 5: Update FastAPI Application

5.1 Mount auth router in main app

In codeframe/ui/server.py (or wherever the FastAPI app is defined):

from codeframe.auth.router import router as auth_router

# Add auth routes
app.include_router(auth_router)

5.2 Create async database session (if not already async)

You may need to add an async session factory for fastapi-users. In codeframe/db.py:

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker

ASYNC_DATABASE_URL = "sqlite+aiosqlite:///./codeframe.db"

async_engine = create_async_engine(ASYNC_DATABASE_URL)
async_session_maker = async_sessionmaker(async_engine, expire_on_commit=False)


async def get_async_session() -> AsyncSession:
    async with async_session_maker() as session:
        yield session

Phase 6: Update Database Schema

6.1 Create migration for users table

If using Alembic:

alembic revision --autogenerate -m "Add users table for fastapi-users"
alembic upgrade head

Or if letting SQLAlchemy create tables:

# In startup or a script
async with async_engine.begin() as conn:
    await conn.run_sync(Base.metadata.create_all)

6.2 Clean up old Better-Auth tables

Remove any tables created by Better-Auth:

  • user (singular)
  • session
  • account
  • Any Drizzle migration tracking tables

Phase 7: Update Existing Protected Routes

7.1 Add auth dependency to existing routes

Before:

@app.get("/api/projects")
async def get_projects():
    return {"projects": [...]}

After:

from codeframe.auth.users import current_active_user
from codeframe.auth.models import User

@app.get("/api/projects")
async def get_projects(user: User = Depends(current_active_user)):
    # user.id is now available for scoping
    return db.query(Project).filter(Project.owner_id == user.id).all()

7.2 Create tenant context dependency (for multi-tenant scoping)

from fastapi import Depends
from codeframe.auth.users import current_active_user
from codeframe.auth.models import User


async def get_tenant_context(user: User = Depends(current_active_user)) -> dict:
    """Dependency that provides user and tenant context."""
    return {
        "user_id": user.id,
        "tenant_id": user.tenant_id,
    }

Phase 8: Update Frontend Auth Client

8.1 Create new auth client (web-ui/lib/auth.ts)

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';

interface AuthTokens {
  access_token: string;
  token_type: string;
}

interface User {
  id: string;
  email: string;
  display_name?: string;
  is_active: boolean;
  is_superuser: boolean;
  is_verified: boolean;
}

export async function login(email: string, password: string): Promise<AuthTokens> {
  const formData = new URLSearchParams();
  formData.append('username', email);  // fastapi-users expects 'username'
  formData.append('password', password);

  const res = await fetch(`${API_URL}/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: formData,
  });

  if (!res.ok) {
    const error = await res.json();
    throw new Error(error.detail || 'Login failed');
  }
  
  return res.json();
}

export async function register(email: string, password: string, displayName?: string): Promise<User> {
  const res = await fetch(`${API_URL}/auth/register`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email,
      password,
      display_name: displayName,
    }),
  });

  if (!res.ok) {
    const error = await res.json();
    throw new Error(error.detail || 'Registration failed');
  }
  
  return res.json();
}

export async function getCurrentUser(token: string): Promise<User> {
  const res = await fetch(`${API_URL}/users/me`, {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (!res.ok) {
    throw new Error('Failed to get user');
  }
  
  return res.json();
}

export async function logout(token: string): Promise<void> {
  await fetch(`${API_URL}/auth/logout`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
  });
}

8.2 Create auth context/hook (web-ui/lib/auth-context.tsx)

'use client';

import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { login as apiLogin, logout as apiLogout, getCurrentUser, register as apiRegister } from './auth';

interface User {
  id: string;
  email: string;
  display_name?: string;
}

interface AuthContextType {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  register: (email: string, password: string, displayName?: string) => Promise<void>;
  logout: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [token, setToken] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // Check for existing token on mount
    const storedToken = localStorage.getItem('auth_token');
    if (storedToken) {
      getCurrentUser(storedToken)
        .then(setUser)
        .catch(() => localStorage.removeItem('auth_token'))
        .finally(() => setIsLoading(false));
      setToken(storedToken);
    } else {
      setIsLoading(false);
    }
  }, []);

  const login = async (email: string, password: string) => {
    const { access_token } = await apiLogin(email, password);
    localStorage.setItem('auth_token', access_token);
    setToken(access_token);
    const user = await getCurrentUser(access_token);
    setUser(user);
  };

  const register = async (email: string, password: string, displayName?: string) => {
    await apiRegister(email, password, displayName);
    // Auto-login after registration
    await login(email, password);
  };

  const logout = async () => {
    if (token) {
      await apiLogout(token);
    }
    localStorage.removeItem('auth_token');
    setToken(null);
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, token, isLoading, login, register, logout }}>
      {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;
}

Phase 9: Testing

9.1 Backend auth tests (tests/auth/test_auth.py)

"""Tests for authentication endpoints."""
import pytest
from httpx import AsyncClient

from codeframe.ui.server import app


@pytest.mark.asyncio
async def test_register_user():
    async with AsyncClient(app=app, base_url="http://test") as client:
        response = await client.post(
            "/auth/register",
            json={
                "email": "test@example.com",
                "password": "securepassword123",
            },
        )
        assert response.status_code == 201
        data = response.json()
        assert data["email"] == "test@example.com"
        assert "id" in data


@pytest.mark.asyncio
async def test_login_user():
    async with AsyncClient(app=app, base_url="http://test") as client:
        # Register first
        await client.post(
            "/auth/register",
            json={"email": "login@example.com", "password": "password123"},
        )
        
        # Login
        response = await client.post(
            "/auth/login",
            data={"username": "login@example.com", "password": "password123"},
        )
        assert response.status_code == 200
        data = response.json()
        assert "access_token" in data


@pytest.mark.asyncio
async def test_protected_route_without_token():
    async with AsyncClient(app=app, base_url="http://test") as client:
        response = await client.get("/users/me")
        assert response.status_code == 401


@pytest.mark.asyncio
async def test_protected_route_with_token():
    async with AsyncClient(app=app, base_url="http://test") as client:
        # Register and login
        await client.post(
            "/auth/register",
            json={"email": "protected@example.com", "password": "password123"},
        )
        login_response = await client.post(
            "/auth/login",
            data={"username": "protected@example.com", "password": "password123"},
        )
        token = login_response.json()["access_token"]
        
        # Access protected route
        response = await client.get(
            "/users/me",
            headers={"Authorization": f"Bearer {token}"},
        )
        assert response.status_code == 200
        assert response.json()["email"] == "protected@example.com"

9.2 Frontend auth tests

Add Playwright E2E tests for login/register flow.


Environment Variables

Add to .env.example:

# Authentication
AUTH_SECRET=your-secret-key-here-use-openssl-rand-hex-32

Migration Checklist

  • Phase 1: Remove Better-Auth from frontend
    • Uninstall npm packages
    • Delete config files
    • Clean up env vars
  • Phase 2: Install fastapi-users
    • Add dependencies to pyproject.toml
    • Create auth module structure
  • Phase 3: Implement models
    • User model with UUID PK
    • Pydantic schemas
  • Phase 4: Configure fastapi-users
    • User manager
    • JWT auth backend
    • Mount routes
  • Phase 5: Update FastAPI app
    • Mount auth router
    • Create async session if needed
  • Phase 6: Database migration
    • Create users table
    • Remove Better-Auth tables
  • Phase 7: Update protected routes
    • Add current_active_user dependency
    • Create tenant context helper
  • Phase 8: Update frontend
    • New auth client
    • Auth context/provider
    • Update components using auth
  • Phase 9: Testing
    • Backend unit tests
    • E2E tests for auth flow
    • Verify multi-tenant scoping works

Acceptance Criteria

  1. Registration works: POST /auth/register creates user in Python-owned users table
  2. Login returns JWT: POST /auth/login returns { access_token, token_type }
  3. Protected routes work: Routes with current_active_user dependency reject unauthenticated requests (401)
  4. User ID available: user.id is accessible in route handlers for multi-tenant scoping
  5. Frontend can authenticate: Login/register forms work end-to-end
  6. No Better-Auth remnants: All JS auth code removed, no Drizzle, no Better-Auth tables
  7. Tests pass: Both backend unit tests and E2E auth tests pass

Related Issues

  • Closes existing auth-related issues

Labels

bug, P0-blocker-beta, auth, refactor

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0-blocker-betaCritical blocker - must fix before beta testingarchitectureSystem architecture and design patterns

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions