- Add shared/ module: config, db, models, schemas, redis utilities - Add Auth Service (services/auth/): register, login, refresh, me, ForwardAuth /verify endpoint for Traefik - Add Traefik config: ACME/Cloudflare DNS-01, dynamic routing, ForwardAuth middleware, sticky sessions for WS Gateway - Add service scaffolds: ws-gateway, chat, batch-agent, billing (READMEs) - Add redis>=5.0.0 to requirements.txt - Monolith app/ is untouched — strangler fig migration
65 lines
2.2 KiB
Python
65 lines
2.2 KiB
Python
"""ForwardAuth verification endpoint for Traefik.
|
|
|
|
Traefik calls GET /api/v1/auth/verify on every request to a protected
|
|
service. This endpoint validates the JWT from the Authorization header
|
|
and returns identity headers that Traefik injects into downstream requests.
|
|
|
|
Downstream services NEVER validate JWTs themselves — they trust the
|
|
X-User-Id, X-User-Email, X-User-Tier headers injected by Traefik.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Request, Response
|
|
from fastapi import status as http_status
|
|
from jose import JWTError, jwt
|
|
from sqlalchemy import select
|
|
|
|
from shared.config import settings
|
|
from shared.db import async_session
|
|
from shared.models import Subscription
|
|
|
|
router = APIRouter(tags=["auth"])
|
|
|
|
|
|
@router.get("/auth/verify")
|
|
async def verify(request: Request) -> Response:
|
|
"""Validate JWT and return identity headers for Traefik ForwardAuth.
|
|
|
|
Returns 200 with X-User-* headers on success, 401 on failure.
|
|
Traefik copies response headers to the downstream request.
|
|
"""
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
return Response(status_code=http_status.HTTP_401_UNAUTHORIZED)
|
|
|
|
token = auth_header[7:] # strip "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:
|
|
return Response(status_code=http_status.HTTP_401_UNAUTHORIZED)
|
|
except JWTError:
|
|
return Response(status_code=http_status.HTTP_401_UNAUTHORIZED)
|
|
|
|
# Live tier lookup from subscriptions table
|
|
async with async_session() as db:
|
|
result = await db.execute(
|
|
select(Subscription.tier).where(Subscription.user_id == user_id)
|
|
)
|
|
default_tier = "power" if settings.ENV == "dev" else "free"
|
|
tier: str = result.scalar_one_or_none() or default_tier
|
|
|
|
return Response(
|
|
status_code=http_status.HTTP_200_OK,
|
|
headers={
|
|
"X-User-Id": user_id,
|
|
"X-User-Email": email,
|
|
"X-User-Tier": tier,
|
|
},
|
|
)
|