"""Auth middleware — JWT validation dependency. ``get_current_user`` is the FastAPI dependency used by all protected routes. It decodes the Bearer JWT (identity + expiry), then fetches the current tier from the ``subscriptions`` table so that tier changes take effect immediately without requiring token re-issue. Exempt routes (no JWT required): - POST /api/v1/auth/register - POST /api/v1/auth/login - POST /api/v1/billing/webhook """ from __future__ import annotations from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config.settings import settings from app.db import get_session from app.schemas import UserProfile oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") async def get_current_user( token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_session), ) -> UserProfile: """Validate a Bearer JWT and return the authenticated user. The JWT is used for identity and expiry only. The tier is fetched live from the ``subscriptions`` table so that upgrades/downgrades take effect immediately. Falls back to ``'free'`` when no subscription row exists. Raises HTTP 401 on any invalid or expired token. """ credentials_exc = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode( token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM] ) user_id: str | None = payload.get("sub") email: str | None = payload.get("email") if not user_id or not email: raise credentials_exc except JWTError: raise credentials_exc # Live tier lookup — subscription row is the authoritative source. from app.models import Subscription # noqa: PLC0415 result = await db.execute( select(Subscription.tier).where(Subscription.user_id == user_id) ) tier: str = result.scalar_one_or_none() or "free" return UserProfile(id=user_id, email=email, tier=tier) # type: ignore[arg-type]