Construyendo a hey-frank #3 — Auth con Firebase
Qué conseguimos al terminar: login real (Google y/o email) con Firebase, verificación del token en la API, la cookie firmada para el streaming (SSE), y el reemplazo del
user_id = 1placeholder por la identidad real.
Crédito: estas notas están fuertemente basadas en la serie Building a Production-Ready Agent Stack de Bedir Tapkan (bedirtapkan.com). Gracias por el material original; acá lo adapté a mi stack (Gemini/ADK + Firebase).
Cuál es el plan
Queremos proteger con autenticación dos cosas, y necesitan dos mecanismos:
- La API REST → la llamas con
fetch(), que sí manda headers. Usas un JWT (JSON Web Token: un “pase” firmado) enAuthorization: Bearer <token>. - El streaming (SSE) → se abre con
EventSource, que NO permite headers custom. Solución: una cookie firmada que el navegador manda solo.
API REST → Authorization: Bearer <ID token de Firebase>
SSE → Cookie firmada HttpOnly (el navegador la manda solo)
Firebase es el proveedor OIDC: maneja el login y le da al frontend un ID token (JWT firmado
por Firebase). Tu backend solo lo verifica. El audience e issuer se derivan de tu
project id.
Para verificar tokens NO necesitas la SDK
firebase-adminni un service account — bastagoogle-authcon el project id. (La SDK admin solo hace falta para operaciones server-side como revocar sesiones o gestionar usuarios.)
Paso 1 — Crear el proyecto Firebase
📋 En console.firebase.google.com:
- Add project.
- Build → Authentication → Get started.
- Sign-in method: habilita Google (y Email/Password si quieres).
- Project settings → Your apps → Web app (
</>): registra una app web y copia el objetofirebaseConfig(apiKey, authDomain, projectId, appId) — son públicos, van en el frontend. - Anota el Project ID (lo usa el backend como audience).
Paso 2 — Dependencias del backend
📋 Comando — desde backend/:
uv add google-auth requests itsdangerous
- google-auth + requests → verifican el ID token de Firebase (firma, issuer, audience, exp).
- itsdangerous → firma la cookie de SSE (HMAC).
⚠️
requestses obligatorio: lo usa el transport de google-auth.
Paso 3 — Settings + .env
📋 Agregar a core/settings.py:
# Firebase
firebase_project_id: str
# Firma de la cookie de SSE
cookie_secret: str
session_cookie_name: str = "stream_session"
session_cookie_max_age: int = 600 # 10 minutos
api_base_url: str | None = None
@field_validator("cookie_secret")
@classmethod
def validate_cookie_secret(cls, v: str) -> str:
if len(v) < 32:
raise ValueError("COOKIE_SECRET must be at least 32 characters.")
return v
📋 backend/.env:
FIREBASE_PROJECT_ID=mi-proyecto-firebase
COOKIE_SECRET=<mín 32 chars: python -c "import secrets; print(secrets.token_urlsafe(32))">
SESSION_COOKIE_NAME=stream_session
SESSION_COOKIE_MAX_AGE=600
Paso 4 — core/auth.py: verificación + cookie
Por qué: lógica pura de auth.
- (a) Verifica el ID token de Firebase,
- (b) Firma/verifica la cookie de SSE,
- (c) Expone dependencies de FastAPI.
En puertos y adaptadores:
verify_jwtes el adaptador de salida de identidad. Su contrato: “dame un token, te digo quién es”. Hoy lo cumple Firebase; cambiarlo por Auth0 sería otro adaptador, sin tocar el núcleo.
📋 Código — backend/app/core/auth.py:
import hashlib
from fastapi import HTTPException, Request, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token
from app.core.settings import settings
security = HTTPBearer() # hace que Swagger muestre "Authorize"
_google_request = google_requests.Request() # cachea los certs de Firebase
# --- Verificación del ID token de Firebase ---
async def verify_jwt(token: str) -> dict:
"""Verifica un ID token de Firebase. Devuelve el payload o lanza 401."""
try:
# firma + exp + issuer (securetoken.google.com/<pid>) + audience (<pid>)
return id_token.verify_firebase_token(token, _google_request, settings.firebase_project_id)
except ValueError as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token: {e}")
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Security(security),
) -> dict:
return await verify_jwt(credentials.credentials)
async def get_current_user_id(user: dict = Security(get_current_user)) -> str:
return user["sub"] # el uid de Firebase
# --- Cookie firmada para SSE (HMAC-SHA256) — agnóstica del proveedor ---
cookie_signer = URLSafeTimedSerializer(
settings.cookie_secret, salt="stream-session",
signer_kwargs={"digest_method": hashlib.sha256},
)
def create_stream_cookie(user_id: str) -> str:
return cookie_signer.dumps(user_id)
def verify_stream_cookie(cookie_value: str) -> str:
try:
return cookie_signer.loads(cookie_value, max_age=settings.session_cookie_max_age)
except SignatureExpired:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Stream session expired.")
except BadSignature:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid stream session cookie")
async def get_user_from_stream_cookie(request: Request) -> str:
cookie_value = request.cookies.get(settings.session_cookie_name)
if not cookie_value:
raise HTTPException(status.HTTP_401_UNAUTHORIZED,
"No stream session. Call POST /auth/session first.")
return verify_stream_cookie(cookie_value)
Paso 5 — core/cookies.py: atributos según entorno
Por qué: una cookie no es solo un valor — viaja con atributos que le dicen al navegador
cuándo mandarla y cuándo protegerla. Si los pones mal, pasa una de dos: o el navegador no
manda la cookie (y el SSE falla con 401), o la manda de forma insegura (viaja por HTTP plano y
cualquiera la lee). El problema es que los valores correctos dependen del entorno: en localhost
(dev) y en producción con HTTPS las reglas del navegador son distintas. Esta función calcula los
atributos correctos para cada caso, en un solo lugar.
📋 Código:
from urllib.parse import urlparse
from app.core.settings import settings
def cookie_attrs_for_sse():
origin = (settings.cors_origins or [""])[0]
api = settings.api_base_url or origin
o, a = urlparse(origin), urlparse(api)
cross_site = (o.scheme, o.hostname, o.port) != (a.scheme, a.hostname, a.port)
same_site = "none" if cross_site else "lax"
secure = settings.env != "development" or cross_site
return same_site, secure, "/stream/" # la cookie solo viaja a /stream/
Los cuatro atributos, uno por uno
- HttpOnly — la cookie es invisible a JavaScript (
document.cookieno la ve). Protege de XSS (Cross-Site Scripting: si un atacante logra inyectar JS en tu página, igual no puede robar la cookie). Tu sesión de stream nunca necesita leerse desde JS, así que vaHttpOnlysiempre. - Path=/stream/ — la cookie solo se manda a URLs que empiezan con
/stream/(los endpoints del agente, receta 04). A/api/...no viaja. Es el principio de mínimo privilegio: la credencial de SSE no anda paseándose por toda la app. - Max-Age=600 — vive 10 minutos y el navegador la borra sola. Una sesión de stream es de
corta duración; no queremos una cookie eterna. (Sale de
settings.session_cookie_max_age.) - Secure — si está activo, el navegador solo manda la cookie por HTTPS (no por HTTP plano).
Imprescindible en producción; en
localhost(que es HTTP) tiene que ir apagado, o el navegador nunca mandaría la cookie en dev. De ahí que se calcule según el entorno (ver abajo).
Por qué SameSite y Secure se calculan (no son fijos)
El meollo de la función es decidir dos atributos que dependen de si el front y el back están en el mismo sitio o no:
cross_site = (o.scheme, o.hostname, o.port) != (a.scheme, a.hostname, a.port)
cross_site = ¿el origen del frontend y el de la API son distintos sitios? Compara esquema
(http/https), host y puerto. En dev, front en :5173 y back en :8000 → puertos distintos →
cross_site = True. En producción, si ambos cuelgan del mismo dominio → False.
same_site = "none" if cross_site else "lax"
SameSite controla si la cookie viaja en peticiones entre sitios (otra reglas del navegador
contra CSRF):
"lax"→ solo viaja dentro del mismo sitio. Sirve cuando front y back comparten dominio."none"→ viaja también entre sitios distintos. Necesario cuando el front está en otro origen que la API (tu caso en dev). Ojo: el navegador exige queSameSite=Nonevenga siempre conSecure— por eso la línea de abajo fuerzasecurecuando hay cross-site.
secure = settings.env != "development" or cross_site
Secure queda activo si (a) no estás en development (producción → HTTPS), o (b) hay
cross-site (porque SameSite=None lo obliga). Solo se apaga en el caso “dev y mismo sitio”, que es
HTTP plano y donde un Secure=True rompería el envío de la cookie.
En una frase:
HttpOnlyyPathson fijos (seguridad siempre);SameSiteySecurese ajustan según si front/back comparten sitio y si estás en dev o prod, para que la cookie se mande cuando debe y solo de forma segura.
Cómo conecta: esta función la llama api/auth.py (Paso 7) justo cuando hace response.set_cookie(...)
en POST /auth/session. Devuelve la tripleta (same_site, secure, path) que se le pasa a esa
llamada. Si alguna vez tu SSE “no recibe la cookie”, el 90% de las veces el culpable es uno de estos
atributos mal calculado para tu entorno.
Paso 6 — user_service.py: el puente de identidad
Por qué: el token trae el sub (el uid externo de Firebase). Internamente usamos un user.id
entero. Este service traduce uno en otro, creando el usuario en su primer login.
📋 Código — backend/app/domain/services/user_service.py:
from sqlalchemy.ext.asyncio import AsyncSession
from app.persistence.models import User
from app.persistence.repositories.user_repo import user_repo
class UserService:
async def get_or_create_from_subject( # "subject" = el claim "sub" del token de Firebase
self, db: AsyncSession, subject: str,
email: str | None = None, name: str | None = None,
) -> User:
user = await user_repo.get_by_subject(db, subject)
if user:
updated = False
if email is not None and user.email != email:
user.email = email; updated = True
if name is not None and user.name != name:
user.name = name; updated = True
if updated:
await db.flush(); await db.refresh(user)
return user
from sqlalchemy.exc import IntegrityError
try:
return await user_repo.create(db, subject=subject, email=email, name=name)
except IntegrityError: # race condition: dos logins simultáneos
await db.rollback()
return await user_repo.get_by_subject(db, subject)
user_service = UserService()
subjectes el claimsubdel token (el uid de Firebase). Nombre neutro, agnóstico del proveedor.
Paso 7 — api/auth.py: endpoints de auth
Por qué: el endpoint que intercambia el JWT por la cookie de SSE, más perfil y logout.
📋 Código — backend/app/api/auth.py:
from fastapi import APIRouter, Depends, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies import get_session
from app.core.auth import create_stream_cookie, get_current_user, get_current_user_id
from app.core.cookies import cookie_attrs_for_sse
from app.core.settings import settings
from app.domain.services.user_service import user_service
router = APIRouter(prefix="/auth", tags=["authentication"])
@router.post("/session", status_code=status.HTTP_204_NO_CONTENT)
async def create_session_cookie(
response: Response,
db: AsyncSession = Depends(get_session),
user_id: str = Depends(get_current_user_id), # verifica el ID token
):
async with db.begin():
await user_service.get_or_create_from_subject(db, user_id)
cookie_value = create_stream_cookie(user_id)
same_site, secure, path = cookie_attrs_for_sse()
response.set_cookie(
key=settings.session_cookie_name, value=cookie_value,
max_age=settings.session_cookie_max_age,
httponly=True, secure=secure, samesite=same_site, path=path,
)
@router.get("/me")
async def get_current_user_info(
user_data: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
):
user = await user_service.get_or_create_from_subject(
db, subject=user_data["sub"],
email=user_data.get("email", ""), name=user_data.get("name", ""),
)
return {"id": user.id, "subject": user.subject,
"email": user.email, "name": user.name,
"created_at": user.created_at.isoformat()}
@router.delete("/session", status_code=status.HTTP_204_NO_CONTENT)
async def delete_session_cookie(response: Response):
response.delete_cookie(key=settings.session_cookie_name, path="/stream/")
Registra el router en main.py: from app.api import auth; app.include_router(auth.router).
Paso 8 — Proteger los endpoints (¡adiós user_id = 1!)
Antes de tener auth, los endpoints usaban user_id: int = 1. Ahora sacamos la identidad real del token.
El patrón se repite en cada handler de dos archivos:
backend/app/api/sessions.py— 4 handlers:create_session,list_sessions,get_session,delete_session.backend/app/api/messages.py— 2 handlers:create_message,list_messages.
En los 6 es el mismo cambio: borras el user_id: int = 1 y lo reemplazas por
subject: str = Depends(get_current_user_id), y al inicio del handler traduces subject → user.id
con user_service.get_or_create_from_subject.
# ANTES:
async def create_session(data, db=Depends(get_session), user_id: int = 1):
return await session_service.create_session(db, user_id, data.title)
# AHORA:
from app.core.auth import get_current_user_id
from app.domain.services.user_service import user_service
async def create_session(data, db=Depends(get_session),
subject: str = Depends(get_current_user_id)): # verifica el JWT
user = await user_service.get_or_create_from_subject(db, subject) # sub -> user.id
return await session_service.create_session(db, user.id, data.title)
Aplica lo mismo a los 4 handlers de backend/app/api/sessions.py y los 2 de
backend/app/api/messages.py. Resultado: cualquier llamada sin un token válido → 401 automático.
💡 Ojo con los imports: además de cambiar las firmas, agrega arriba de cada archivo
from app.core.auth import get_current_user_idyfrom app.domain.services.user_service import user_service. Y como ahorauser_service.get_or_create_from_subjectpuede escribir (crea el usuario en su primer login), recuerda que el commit lo sigue haciendoget_db()— no metasdb.begin()en el handler.
Paso 9 — Frontend: login con Firebase
📋 Dependencia:
Como el frontend corre en Docker y su node_modules es un volumen aparte
(node_modules:/app/node_modules en el compose), instala dentro del container para que Vite lo
vea. Desde infra/:
docker compose exec frontend npm install firebase
Eso instala firebase en el volumen del container y, como /app está bind-montado, actualiza tu
package.json y package-lock.json en el host. Si Vite no recarga solo: docker compose restart frontend.
🔁 Discrepancia doc-vs-Docker: un
npm install firebasecorrido solo en el host no llega alnode_modulesdel container → Vite falla conFailed to resolve import "firebase/auth". O lo instalas en el container (arriba), o instalas en el host y reinicias el frontend (elcommandcorrenpm cial arrancar). 📋frontend/.env(valores públicos):
VITE_FIREBASE_API_KEY=AIza...
VITE_FIREBASE_AUTH_DOMAIN=mi-proyecto.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=mi-proyecto-firebase
VITE_FIREBASE_APP_ID=1:123:web:abc
VITE_API_URL=http://localhost:8000
📋 frontend/src/firebase.ts:
import { initializeApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'
const app = initializeApp({
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
})
export const auth = getAuth(app) // los nombres pueden variar según tu versión de firebase
📋 Login / logout / token — frontend/src/auth.ts:
Un módulo pequeño que envuelve el SDK de Firebase en tres funciones que tu app React va a llamar.
(Va aparte de firebase.ts: ese inicializa la conexión al proyecto; este expone las acciones
de auth.)
import { GoogleAuthProvider, signInWithPopup, signOut } from 'firebase/auth'
import { auth } from './firebase'
export const loginWithGoogle = () => signInWithPopup(auth, new GoogleAuthProvider())
export const logout = () => signOut(auth)
// getIdToken() REFRESCA solo si expiró -> no te desloguea cada hora
export async function getToken(): Promise<string | null> {
return auth.currentUser ? await auth.currentUser.getIdToken() : null
}
loginWithGoogle— abre el popup de Google; el usuario elige su cuenta, Firebase lo autentica y guarda la sesión en el navegador. La conectas a un botón “Iniciar sesión”.logout— cierra la sesión: borra el estado que Firebase guardó; tras estoauth.currentUserquedanull.getToken— devuelve el ID token (el JWT) del usuario logueado, onullsi no hay nadie.getIdToken()refresca solo si expiró (los tokens duran 1 h) → no te desloguea cada hora.
Ese ID token va como Authorization: Bearer <token> en fetchWithAuth, y en POST /auth/session
para obtener la cookie de SSE — igual que un JWT normal. (El client.ts y sse.ts quedan como en
el patrón estándar; solo cambia que el token sale de getToken().)
loginWithGoogle() → Firebase guarda la sesión
│
▼
getToken() → el ID token (JWT)
│
├──► Authorization: Bearer <token> en cada fetch a /api/... (auth REST, Paso 8)
└──► POST /auth/session con ese Bearer → el backend devuelve la cookie de SSE (Paso 7)
💡 Ventaja Firebase:
getIdToken()refresca en segundo plano. UsaonIdTokenChanged(auth, cb)para reaccionar en la UI. Los nombres pueden variar según el SDK que tengas instalado.
Paso 10 — Probar que funciona (end-to-end)
La auth se prueba en dos tandas: lo que puedes verificar ya mismo (el rechazo, sin login), y el camino feliz (con un token real). Hazlas en ese orden.
A) El rechazo — pruébalo YA, sin frontend ni token
Esto no necesita Firebase configurado: solo confirma que los endpoints quedaron protegidos. Desde
infra/:
# Sin token → 401 (antes esto devolvía datos con user_id=1)
curl -i http://localhost:8000/api/sessions/
# Sin token tampoco te deja crear la cookie de SSE → 401/403
curl -i -X POST http://localhost:8000/auth/session
Si ves HTTP/1.1 401 Unauthorized en ambos, el Paso 8 quedó bien: ya nadie entra sin identidad.
✅ Marca del checklist:
GET /api/sessions/sin token → 401.
B) Conseguir un ID token real — dos caminos
El resto del checklist necesita un token de verdad. Tienes dos formas según qué proveedor activaste:
Camino 1 — desde el navegador (Google). Es el real de producción; lo haces en la parte C.
Camino 2 — por curl, sin frontend (solo si activaste Email/Password). Atajo para verificar el
backend sin UI. Pídele un token a la API REST de Firebase con tu apiKey:
# (una vez) crear un usuario de prueba
curl "https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"test1234","returnSecureToken":true}'
# loguearte para obtener el idToken
curl "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"test1234","returnSecureToken":true}'
Copia el idToken de la respuesta y úsalo contra tu backend:
TOKEN="pega-aquí-el-idToken"
# /auth/me debe devolver tu perfil (sub, email, name)
curl -s http://localhost:8000/auth/me -H "Authorization: Bearer $TOKEN"
# /auth/session debe devolver 204 + Set-Cookie (mira el -v)
curl -v -X POST http://localhost:8000/auth/session -H "Authorization: Bearer $TOKEN"
En el segundo, busca en la salida el header Set-Cookie: stream_session=...; HttpOnly; Path=/stream/.
C) El camino feliz completo, en el navegador
La receta da las funciones de auth (auth.ts) pero no una UI con botones. Para probar, arma un
componente de prueba desechable en frontend/src/App.tsx (luego lo reemplazas por la UI real):
import { useState } from 'react'
import { loginWithGoogle, logout, getToken } from './auth'
export default function App() {
const [out, setOut] = useState('')
async function probarMe() {
const token = await getToken()
const r = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
})
setOut(JSON.stringify(await r.json(), null, 2))
}
return (
<div style={{ padding: 24, fontFamily: 'sans-serif' }}>
<button onClick={loginWithGoogle}>Login con Google</button>
<button onClick={logout}>Logout</button>
<button onClick={probarMe}>Probar /auth/me</button>
<pre>{out}</pre>
</div>
)
}
Luego:
- Levanta todo (desde la raíz):
make dev. El front queda enhttp://localhost:5173. - Abre
http://localhost:5173y abre las DevTools (F12) → pestaña Network. - Clic en “Login con Google” → elige tu cuenta en el popup. Si el navegador bloquea el popup,
permítelo (gotcha
auth/popup-blocked). - Clic en “Probar /auth/me” → abajo debe imprimirse tu perfil (
id,sub,email,name). En Network verás el request a/auth/meconAuthorization: Bearer ...y status200. - Verifica la cookie de SSE: en otra interacción que llame
POST /auth/session, en Network ese request da204conSet-Cookie; en Application → Cookies verásstream_sessionmarcadaHttpOnlyyPath=/stream/(no la puedes leer desde JS, por eso no aparece endocument.cookie). - Confírmalo en la consola de Firebase: Authentication → Users — tu usuario debe aparecer listado tras el primer login.
Si
/auth/meda401conInvalid token: ... audience, casi seguro elprojectIddel frontend (VITE_FIREBASE_PROJECT_ID) y elFIREBASE_PROJECT_IDdel backend no coinciden. Es el gotcha #1.
Con esto recorres el checklist de abajo de punta a punta.
Conceptos clave
- ID token, no access token: usas el token de identidad; el
audiencees tu project id. - El proveedor OIDC es intercambiable: el 95% de la auth no se entera del cambio.
- La cookie de SSE es agnóstica: la firmas con tu
COOKIE_SECRET, da igual quién emitió el JWT.
Transparencia y debugging (auth)
- Ver qué trae el token: un JWT no está encriptado, solo firmado. Decodifícalo en https://jwt.io
(nunca un token de prod) o con
python -c "from jose import jwt; print(jwt.get_unverified_claims(t))". Revisa queaud== tu project id y queexpno haya pasado. - Inspeccionar la cookie:
curl -v -X POST .../auth/session -H "Authorization: Bearer $TOKEN"→ mira elSet-Cookie(debe tenerHttpOnly; Path=/stream/). - Sonda rápida:
GET /auth/me— si devuelve tu perfil, el token está OK. - DevTools: Network (Authorization + Set-Cookie), Application→Cookies (la HttpOnly se ve ahí),
Console (errores de Firebase como
auth/popup-blocked).
Checklist de “auth lista”
-
GET /api/sessions/sin token →401 -
POST /auth/sessioncon un ID token de Firebase →204+Set-Cookie -
GET /auth/medevuelvesub(uid),email,name - Tras ~1h la sesión sigue viva (refresh automático)
- El usuario aparece en Firebase → Authentication → Users