feat: migrate authentication to Better-Auth (Phase 1)

Replace hand-rolled JWT auth with Better-Auth session-based authentication.

- Scaffold auth/ Node.js service with Better-Auth, bcrypt password compat,
  Postgres adapter mapped to existing users table
- Add Alembic migration (002) creating sessions, accounts, verifications
  tables and migrating password hashes to accounts table
- Update FastAPI auth dependency to validate sessions via shared DB
  (supports both cookie and Bearer token)
- Remove registration/login/refresh endpoints from API gateway (now
  handled by Better-Auth service)
- Update frontend to use better-auth/react client with httpOnly cookies
  (no tokens in localStorage or memory)
- Rewrite auth store, Login, Register, Dashboard, Settings, ProtectedRoute
  to use session-based auth
- Update all tests to create sessions directly in DB instead of JWT tokens

Resolves CAR-27
See plan: CAR-26#document-plan

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Coupon Carl
2026-03-28 04:46:10 +00:00
parent b7e6f637a7
commit 906d7023a9
11 changed files with 505 additions and 479 deletions
+71 -17
View File
@@ -1,34 +1,88 @@
"""FastAPI dependency injection for authentication."""
"""FastAPI dependency injection for authentication.
Validates Better-Auth session tokens from cookies or Bearer header.
Sessions are verified by querying the shared sessions table directly.
"""
from datetime import UTC, datetime
from uuid import UUID
from fastapi import Depends, Header, HTTPException, status
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.jwt import decode_token
from cartsnitch_api.config import settings
from cartsnitch_api.database import get_db
bearer_scheme = HTTPBearer()
# Keep Bearer scheme as optional — Better-Auth primarily uses cookies,
# but we support Bearer tokens for service-to-service or mobile clients.
bearer_scheme = HTTPBearer(auto_error=False)
# Better-Auth session cookie name
SESSION_COOKIE_NAME = "better-auth.session_token"
async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
"""Validate a Better-Auth session token against the sessions table.
Returns the user_id (as UUID) if the session is valid and not expired.
"""
result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": token},
)
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid session token",
)
user_id, expires_at = row
if expires_at.tzinfo is None:
# Treat naive datetimes as UTC
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < datetime.now(UTC):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired",
)
return UUID(str(user_id))
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> UUID:
try:
payload = decode_token(credentials.credentials)
except ValueError:
"""Extract and validate the session token from cookie or Authorization header.
Checks in order:
1. Better-Auth session cookie (primary — web clients)
2. Bearer token in Authorization header (fallback — API clients)
"""
token: str | None = None
# 1. Check session cookie
cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
if cookie_token:
token = cookie_token
# 2. Fall back to Bearer header
if not token and credentials:
token = credentials.credentials
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
) from None
detail="Authentication required",
)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
) from None
return UUID(payload["sub"])
return await _validate_session_token(token, db)
async def verify_service_key(x_service_key: str = Header()) -> None:
+6 -36
View File
@@ -1,4 +1,9 @@
"""Auth routes: register, login, refresh, me, update, delete."""
"""Auth routes: user profile management.
Registration, login, refresh, and session management are handled by
the Better-Auth service (auth/). This router provides user profile
endpoints that query our own user data from the shared database.
"""
from uuid import UUID
@@ -8,10 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import (
LoginRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
UpdateUserRequest,
UserResponse,
)
@@ -20,37 +21,6 @@ from cartsnitch_api.services.auth import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
svc = AuthService(db)
try:
return await svc.register(body.email, body.password, body.display_name)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
svc = AuthService(db)
try:
return await svc.login(body.email, body.password)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password"
) from None
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
svc = AuthService(db)
try:
return await svc.refresh(body.refresh_token)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
) from None
@router.get("/me", response_model=UserResponse)
async def get_me(
user_id: UUID = Depends(get_current_user),
+2
View File
@@ -19,6 +19,8 @@ class Settings(BaseSettings):
# Valid Fernet key for local dev — MUST be overridden in production
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
auth_service_url: str = "http://auth:3001"
cors_origins: list[str] = ["http://localhost:3000", "https://cartsnitch.com"]
receiptwitness_url: str = "http://receiptwitness:8001"
+2 -22
View File
@@ -6,28 +6,8 @@ from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
# ---------- Auth ----------
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=100)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
# Registration, login, and session management are handled by Better-Auth (auth/ service).
# These schemas are for the profile management endpoints only.
class UpdateUserRequest(BaseModel):
+6 -61
View File
@@ -1,67 +1,20 @@
"""Auth service — user registration, login, token management."""
"""Auth service — user profile management.
Registration, login, token management, and session handling are now
handled by the Better-Auth service (auth/). This service provides
user lookup and profile update operations for the API gateway.
"""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.jwt import create_access_token, create_refresh_token, decode_token
from cartsnitch_api.auth.passwords import hash_password, verify_password
from cartsnitch_api.config import settings
class AuthService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def register(self, email: str, password: str, display_name: str) -> dict:
from cartsnitch_api.models import User
existing = await self.db.execute(select(User).where(User.email == email))
if existing.scalar_one_or_none():
raise ValueError("Email already registered")
user = User(
email=email,
hashed_password=hash_password(password),
display_name=display_name,
)
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
return self._make_token_response(user.id)
async def login(self, email: str, password: str) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user or not verify_password(password, user.hashed_password):
raise ValueError("Invalid email or password")
return self._make_token_response(user.id)
async def refresh(self, refresh_token: str) -> dict:
from cartsnitch_api.models import User
try:
payload = decode_token(refresh_token)
except ValueError:
raise ValueError("Invalid refresh token") from None
if payload.get("type") != "refresh":
raise ValueError("Invalid token type") from None
user_id = UUID(payload["sub"])
# Verify the user still exists before issuing new tokens
result = await self.db.execute(select(User).where(User.id == user_id))
if not result.scalar_one_or_none():
raise ValueError("User no longer exists")
return self._make_token_response(user_id)
async def get_user(self, user_id: UUID) -> dict:
from cartsnitch_api.models import User
@@ -115,11 +68,3 @@ class AuthService:
await self.db.delete(user)
await self.db.commit()
def _make_token_response(self, user_id: UUID) -> dict:
return {
"access_token": create_access_token(user_id),
"refresh_token": create_refresh_token(user_id),
"token_type": "bearer",
"expires_in": settings.jwt_access_token_expire_minutes * 60,
}