- Add services/auth/app/config.py with JWT_PRIVATE_KEY and JWT_PUBLIC_KEY (Auth Service local config - private key never leaves this service) - Update routes.py: sign tokens with RS256 private key - Update deps.py + verify.py: verify tokens with RS256 public key - Update shared/config.py: replace JWT_SECRET/JWT_ALGORITHM with JWT_PUBLIC_KEY (for optional local verification by other services) - Add sys.path fix in main.py for local dev without PYTHONPATH
67 lines
2.2 KiB
Python
67 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
|
|
|
|
from app.config import auth_settings
|
|
|
|
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, auth_settings.JWT_PUBLIC_KEY, algorithms=["RS256"]
|
|
)
|
|
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,
|
|
},
|
|
)
|