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 3a31f82c8d
commit cfda1b544d
11 changed files with 562 additions and 527 deletions
+1
View File
@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+7 -2
View File
@@ -12,6 +12,7 @@ CartSnitch is a self-hosted grocery price intelligence platform. This repo (`car
| Directory | Service | Purpose | | Directory | Service | Purpose |
|-----------|---------|---------| |-----------|---------|---------|
| `/` (root) | Frontend | React PWA, mobile-first (this directory) | | `/` (root) | Frontend | React PWA, mobile-first (this directory) |
| `auth/` | Auth | Better-Auth Node.js service (session management, email/password, OAuth) |
| `api/` | API Gateway | Frontend-facing REST API | | `api/` | API Gateway | Frontend-facing REST API |
| `common/` | Common | Shared Python models, schemas, Alembic migrations | | `common/` | Common | Shared Python models, schemas, Alembic migrations |
| `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers | | `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
@@ -166,9 +167,13 @@ frontend/
All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`. All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`.
- JWT auth: store access token in memory (not localStorage), refresh token in httpOnly cookie if possible, or secure storage. - **Authentication via Better-Auth** (`auth/` service). Sessions are managed via httpOnly cookies — no tokens in localStorage or memory.
- Auth service URL configured via `VITE_AUTH_URL` (default: `http://localhost:3001`)
- Frontend uses `better-auth/react` client for sign-in, sign-up, sign-out, and `useSession()` hook
- API gateway validates sessions by querying the shared `sessions` table in Postgres
- Both cookie-based and Bearer token auth are supported (cookies for web, Bearer for API clients)
- TanStack Query handles caching, background refetching, and optimistic updates. - TanStack Query handles caching, background refetching, and optimistic updates.
- API client should handle 401 responses by attempting token refresh before retrying. - API client sends `credentials: 'include'` on all requests to forward session cookies.
## Development Workflow ## Development Workflow
+1
View File
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"better-auth": "^1.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.0.0", "react-router-dom": "^7.0.0",
+17 -2
View File
@@ -1,10 +1,25 @@
import { useEffect } from 'react'
import { Navigate, Outlet } from 'react-router-dom' import { Navigate, Outlet } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
export function ProtectedRoute() { export function ProtectedRoute() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const { data: session, isPending } = authClient.useSession()
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
if (!isAuthenticated) { useEffect(() => {
setAuthenticated(!!session)
}, [session, setAuthenticated])
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-blue border-t-transparent" />
</div>
)
}
if (!session) {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />
} }
+3 -5
View File
@@ -35,7 +35,7 @@ function matchMockRoute<T>(path: string): T | null {
return getMockPriceHistory(priceHistoryMatch[1]) as T return getMockPriceHistory(priceHistoryMatch[1]) as T
} }
// /products?q=search or /products/:id // /products/:id
const productMatch = path.match(/^\/products\/(.+)$/) const productMatch = path.match(/^\/products\/(.+)$/)
if (productMatch) { if (productMatch) {
const product = mockProducts.find((p) => p.id === productMatch[1]) const product = mockProducts.find((p) => p.id === productMatch[1])
@@ -67,19 +67,17 @@ async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
} }
} }
const token = useAuthStore.getState().token
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
...options, ...options,
credentials: 'include', // Send Better-Auth session cookie
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers, ...options?.headers,
}, },
}) })
if (res.status === 401) { if (res.status === 401) {
useAuthStore.getState().logout() useAuthStore.getState().setAuthenticated(false)
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
+7
View File
@@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001",
})
export const { useSession, signIn, signUp, signOut } = authClient
+8 -5
View File
@@ -1,6 +1,6 @@
import React, { Suspense } from 'react' import React, { Suspense } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts' import { authClient } from '../lib/auth-client.ts'
import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts' import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts'
import { StoreIcon } from '../components/StoreIcon.tsx' import { StoreIcon } from '../components/StoreIcon.tsx'
@@ -9,10 +9,13 @@ const LazySparklineCard = React.lazy(() =>
) )
export function Dashboard() { export function Dashboard() {
const user = useAuthStore((s) => s.user) const { data: session, isPending } = authClient.useSession()
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (!isAuthenticated) { if (isPending) {
return <DashboardSkeleton />
}
if (!session) {
return ( return (
<div className="py-8 text-center"> <div className="py-8 text-center">
<h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1> <h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1>
@@ -35,7 +38,7 @@ export function Dashboard() {
) )
} }
return <AuthenticatedDashboard userName={user?.name ?? 'there'} /> return <AuthenticatedDashboard userName={session.user?.name ?? 'there'} />
} }
function AuthenticatedDashboard({ userName }: { userName: string }) { function AuthenticatedDashboard({ userName }: { userName: string }) {
+13 -8
View File
@@ -1,9 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
import { api } from '../lib/api.ts'
import { mockUser } from '../lib/mock-data.ts'
import type { User } from '../types/api.ts'
export function Login() { export function Login() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -11,7 +9,7 @@ export function Login() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const setAuth = useAuthStore((s) => s.setAuth) const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
@@ -24,13 +22,20 @@ export function Login() {
setLoading(true) setLoading(true)
try { try {
const res = await api.post<{ user: User; token: string }>('/auth/login', { email, password }) const { data, error: authError } = await authClient.signIn.email({
setAuth(res.user, res.token) email,
password,
})
if (authError) {
throw new Error(authError.message ?? 'Sign in failed')
}
setAuthenticated(true)
navigate('/') navigate('/')
} catch { } catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') { if (import.meta.env.VITE_MOCK_AUTH === 'true') {
// Fallback to mock auth for demo setAuthenticated(true)
setAuth(mockUser, 'mock-jwt-token')
navigate('/') navigate('/')
} else { } else {
setError('Invalid email or password. Please try again.') setError('Invalid email or password. Please try again.')
+14 -8
View File
@@ -1,9 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
import { api } from '../lib/api.ts'
import { mockUser } from '../lib/mock-data.ts'
import type { User } from '../types/api.ts'
export function Register() { export function Register() {
const [name, setName] = useState('') const [name, setName] = useState('')
@@ -12,7 +10,7 @@ export function Register() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const setAuth = useAuthStore((s) => s.setAuth) const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
@@ -30,13 +28,21 @@ export function Register() {
setLoading(true) setLoading(true)
try { try {
const res = await api.post<{ user: User; token: string }>('/auth/register', { name, email, password }) const { data, error: authError } = await authClient.signUp.email({
setAuth(res.user, res.token) name,
email,
password,
})
if (authError) {
throw new Error(authError.message ?? 'Registration failed')
}
setAuthenticated(true)
navigate('/') navigate('/')
} catch { } catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') { if (import.meta.env.VITE_MOCK_AUTH === 'true') {
// Fallback to mock auth for demo setAuthenticated(true)
setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
navigate('/') navigate('/')
} else { } else {
setError('Registration failed. Please try again.') setError('Registration failed. Please try again.')
+8 -5
View File
@@ -1,18 +1,21 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
import { useThemeStore } from '../stores/theme.ts' import { useThemeStore } from '../stores/theme.ts'
import { StoreIcon } from '../components/StoreIcon.tsx' import { StoreIcon } from '../components/StoreIcon.tsx'
export function Settings() { export function Settings() {
const user = useAuthStore((s) => s.user) const { data: session } = authClient.useSession()
const logout = useAuthStore((s) => s.logout) const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
const navigate = useNavigate() const navigate = useNavigate()
const { theme, setTheme } = useThemeStore() const { theme, setTheme } = useThemeStore()
const connectedStores = user?.connectedStores ?? [] const user = session?.user
const connectedStores: string[] = []
function handleSignOut() { async function handleSignOut() {
logout() await authClient.signOut()
setAuthenticated(false)
navigate('/login') navigate('/login')
} }
+11 -20
View File
@@ -1,27 +1,18 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types/api.ts'
/**
* Minimal auth state for UI reactivity.
*
* Session management is handled by Better-Auth via httpOnly cookies.
* This store only tracks whether we have an active session for UI
* gating (protected routes, nav state). No tokens in memory or localStorage.
*/
interface AuthState { interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean isAuthenticated: boolean
setAuth: (user: User, token: string) => void setAuthenticated: (value: boolean) => void
logout: () => void
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()((set) => ({
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false, isAuthenticated: false,
setAuth: (user, token) => set({ user, token, isAuthenticated: true }), setAuthenticated: (value) => set({ isAuthenticated: value }),
logout: () => set({ user: null, token: null, isAuthenticated: false }), }))
}),
{
name: 'cartsnitch-auth',
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
},
),
)