feat: add Firebase Auth with Google sign-in

Frontend guard shows login screen for unauthenticated users.
Backend verifies Firebase ID tokens on all protected endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
RamonCalvo 2026-03-28 19:04:51 -06:00
parent f0ef26ee15
commit 2a314af419
7 changed files with 62 additions and 6 deletions

View file

@ -2,3 +2,12 @@ POSTGRES_USER=favs
POSTGRES_PASSWORD=favs
POSTGRES_DB=favs
ANTHROPIC_API_KEY=sk-ant-...
# Firebase
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
VITE_FIREBASE_STORAGE_BUCKET=
VITE_FIREBASE_MESSAGING_SENDER_ID=
VITE_FIREBASE_APP_ID=
VITE_FIREBASE_MEASUREMENT_ID=

24
backend/app/auth.py Normal file
View file

@ -0,0 +1,24 @@
import firebase_admin
from firebase_admin import auth as firebase_auth, credentials
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import settings
if settings.firebase_project_id:
firebase_admin.initialize_app(options={"projectId": settings.firebase_project_id})
bearer = HTTPBearer()
async def get_current_user(
cred: HTTPAuthorizationCredentials = Depends(bearer),
) -> dict:
try:
decoded = firebase_auth.verify_id_token(cred.credentials)
return decoded
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)

View file

@ -5,6 +5,7 @@ class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://favs:favs@favs-db:5432/favs"
anthropic_api_key: str = ""
categorize_model: str = "claude-haiku-4-5-20251001"
firebase_project_id: str = ""
model_config = {"env_file": ".env"}

View file

@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_user
from app.database import get_db
from app.models import Bookmark
from app.schemas import BookmarkCreate, BookmarkResponse, BookmarkUpdate
@ -13,7 +14,9 @@ router = APIRouter(prefix="/api/bookmarks", tags=["bookmarks"])
@router.get("/", response_model=list[BookmarkResponse])
async def list_bookmarks(
category: str | None = None, db: AsyncSession = Depends(get_db)
category: str | None = None,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
query = select(Bookmark).order_by(Bookmark.created_at.desc())
if category:
@ -23,7 +26,7 @@ async def list_bookmarks(
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
async def get_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
async def get_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db), _user: dict = Depends(get_current_user)):
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
bookmark = result.scalar_one_or_none()
if not bookmark:
@ -32,7 +35,7 @@ async def get_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db
@router.post("/", response_model=BookmarkResponse, status_code=201)
async def create_bookmark(data: BookmarkCreate, db: AsyncSession = Depends(get_db)):
async def create_bookmark(data: BookmarkCreate, db: AsyncSession = Depends(get_db), _user: dict = Depends(get_current_user)):
bookmark = Bookmark(**data.model_dump())
db.add(bookmark)
await db.commit()
@ -42,7 +45,7 @@ async def create_bookmark(data: BookmarkCreate, db: AsyncSession = Depends(get_d
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
async def update_bookmark(
bookmark_id: uuid.UUID, data: BookmarkUpdate, db: AsyncSession = Depends(get_db)
bookmark_id: uuid.UUID, data: BookmarkUpdate, db: AsyncSession = Depends(get_db), _user: dict = Depends(get_current_user),
):
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
bookmark = result.scalar_one_or_none()
@ -56,7 +59,7 @@ async def update_bookmark(
@router.delete("/{bookmark_id}", status_code=204)
async def delete_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
async def delete_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db), _user: dict = Depends(get_current_user)):
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
bookmark = result.scalar_one_or_none()
if not bookmark:

View file

@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_user
from app.categorizer import categorize_pending
from app.database import get_db
@ -8,6 +9,6 @@ router = APIRouter(tags=["categorize"])
@router.post("/api/categorize")
async def run_categorize(db: AsyncSession = Depends(get_db)):
async def run_categorize(db: AsyncSession = Depends(get_db), _user: dict = Depends(get_current_user)):
count = await categorize_pending(db)
return {"categorized": count}

View file

@ -4,3 +4,4 @@ sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
pydantic-settings==2.7.1
anthropic==0.43.0
firebase-admin==6.6.0

View file

@ -22,6 +22,7 @@ services:
environment:
DATABASE_URL: postgresql+asyncpg://favs:favs@favs-db:5432/favs
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
FIREBASE_PROJECT_ID: ${VITE_FIREBASE_PROJECT_ID}
depends_on:
favs-db:
condition: service_healthy
@ -29,5 +30,21 @@ services:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
favs-ui:
build:
context: ./frontend
args:
- VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY}
- VITE_FIREBASE_AUTH_DOMAIN=${VITE_FIREBASE_AUTH_DOMAIN}
- VITE_FIREBASE_PROJECT_ID=${VITE_FIREBASE_PROJECT_ID}
- VITE_FIREBASE_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET}
- VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID}
- VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_ID}
- VITE_FIREBASE_MEASUREMENT_ID=${VITE_FIREBASE_MEASUREMENT_ID}
ports:
- "3000:80"
depends_on:
- favs-api
volumes:
favs_pgdata: