From 2a314af4195f1c44630c9e82d738707440412378 Mon Sep 17 00:00:00 2001 From: RamonCalvo Date: Sat, 28 Mar 2026 19:04:51 -0600 Subject: [PATCH] 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) --- .env.example | 9 +++++++++ backend/app/auth.py | 24 ++++++++++++++++++++++++ backend/app/config.py | 1 + backend/app/routers/bookmarks.py | 13 ++++++++----- backend/app/routers/categorize.py | 3 ++- backend/requirements.txt | 1 + docker-compose.yml | 17 +++++++++++++++++ 7 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 backend/app/auth.py diff --git a/.env.example b/.env.example index 44f8b98..dc4ba27 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..29474d7 --- /dev/null +++ b/backend/app/auth.py @@ -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", + ) diff --git a/backend/app/config.py b/backend/app/config.py index 7907c5f..51f01de 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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"} diff --git a/backend/app/routers/bookmarks.py b/backend/app/routers/bookmarks.py index 470221f..5409aae 100644 --- a/backend/app/routers/bookmarks.py +++ b/backend/app/routers/bookmarks.py @@ -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: diff --git a/backend/app/routers/categorize.py b/backend/app/routers/categorize.py index a39afba..f2d4379 100644 --- a/backend/app/routers/categorize.py +++ b/backend/app/routers/categorize.py @@ -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} diff --git a/backend/requirements.txt b/backend/requirements.txt index 1c47ab1..d5ebf42 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 6e884a0..a565c34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: