trymeapp/app/routers/auth_routes.py

206 lines
7.4 KiB
Python
Raw Normal View History

2026-11-11 22:06:26 +00:00
from __future__ import annotations
import secrets
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import RedirectResponse
from itsdangerous import BadSignature, URLSafeTimedSerializer
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import (
SESSION_COOKIE_NAME,
create_session_token,
get_or_create_user_from_identity,
)
from app.config import Settings, get_settings
from app.database import get_db
from app.models import User
from app.schemas import AuthConfigOut, DevLoginIn, TokenSessionOut, UserOut
router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"])
def _state_serializer(settings: Settings) -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(settings.session_secret, salt="auth0-oauth-state")
@router.get("/config", response_model=AuthConfigOut)
def get_auth_config(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> AuthConfigOut:
admin_count = db.scalar(select(func.count(User.id)).where(User.is_admin.is_(True))) or 0
admin_unclaimed = (admin_count == 0) and not bool(settings.admin_user.strip())
return AuthConfigOut(
auth0_enabled=settings.auth0_configured,
auth0_domain=settings.auth0_domain,
auth0_client_id=settings.auth0_client_id,
auth0_audience=settings.auth0_audience,
dev_auth_enabled=settings.dev_auth_enabled,
admin_unclaimed=admin_unclaimed,
)
@router.get("/login")
def auth0_login(
return_to: str = Query(default="/"),
settings: Settings = Depends(get_settings),
) -> RedirectResponse:
if not settings.auth0_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Auth0 federated login is not configured on this server. Set AUTH0_DOMAIN and AUTH0_CLIENT_ID.",
)
domain = settings.auth0_domain.strip().rstrip("/")
base = domain if domain.startswith("http") else f"https://{domain}"
state = _state_serializer(settings).dumps(
{"nonce": secrets.token_hex(8), "return_to": return_to}
)
params = {
"response_type": "code",
"client_id": settings.auth0_client_id,
"redirect_uri": settings.auth0_callback_url,
"scope": "openid profile email",
"state": state,
}
if settings.auth0_audience:
params["audience"] = settings.auth0_audience
return RedirectResponse(url=f"{base}/authorize?{urlencode(params)}")
@router.get("/callback")
async def auth0_callback(
code: str = Query(...),
state: str = Query(...),
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> RedirectResponse:
if not settings.auth0_configured or not settings.auth0_client_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Auth0 callback requires AUTH0_DOMAIN, AUTH0_CLIENT_ID, and AUTH0_CLIENT_SECRET.",
)
try:
state_data = _state_serializer(settings).loads(state, max_age=600)
except BadSignature as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OAuth state parameter.",
) from exc
domain = settings.auth0_domain.strip().rstrip("/")
base = domain if domain.startswith("http") else f"https://{domain}"
async with httpx.AsyncClient(timeout=10.0) as client:
token_resp = await client.post(
f"{base}/oauth/token",
json={
"grant_type": "authorization_code",
"client_id": settings.auth0_client_id,
"client_secret": settings.auth0_client_secret,
"code": code,
"redirect_uri": settings.auth0_callback_url,
},
)
if token_resp.status_code >= 400:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Failed to exchange Auth0 authorization code.",
)
tokens = token_resp.json()
userinfo_resp = await client.get(
f"{base}/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
if userinfo_resp.status_code >= 400:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Failed to retrieve user profile from Auth0.",
)
profile = userinfo_resp.json()
user = get_or_create_user_from_identity(
db,
settings,
auth_sub=str(profile["sub"]),
email=profile.get("email"),
preferred_nickname=profile.get("nickname"),
first_name=profile.get("given_name", "") or "",
last_name=profile.get("family_name", "") or "",
)
session_jwt = create_session_token(user.id, user.auth_sub, settings)
return_to = str(state_data.get("return_to") or "/")
if not return_to.startswith("/"):
return_to = "/"
target = f"{settings.frontend_base_url.rstrip('/')}{return_to}"
redirect = RedirectResponse(url=target, status_code=status.HTTP_302_FOUND)
redirect.set_cookie(
key=SESSION_COOKIE_NAME,
value=session_jwt,
httponly=True,
samesite="lax",
max_age=settings.session_ttl_hours * 3600,
)
return redirect
@router.post("/dev-login", response_model=TokenSessionOut)
def dev_login(
payload: DevLoginIn,
response: Response,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> TokenSessionOut:
if not settings.dev_auth_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Local development login is disabled. Please sign in with Auth0.",
)
handle = payload.nickname.strip().lower().replace(" ", "_")
auth_sub = f"dev|{handle}"
user = get_or_create_user_from_identity(
db,
settings,
auth_sub=auth_sub,
email=payload.email or f"{handle}@example.com",
preferred_nickname=payload.nickname.strip(),
)
if payload.as_moderator and not user.is_admin:
# In dev mode, allow moderator login when either ADMIN_USER is unset or matches
configured_admin = settings.admin_user.strip().lower()
if not configured_admin or configured_admin in {
user.auth_sub.lower(),
user.nickname.lower(),
(user.email or "").lower(),
}:
user.is_admin = True
db.commit()
db.refresh(user)
if user.is_banned:
reason_suffix = f" Reason: {user.ban_reason}" if user.ban_reason else ""
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Your account has been suspended by a moderator.{reason_suffix}",
)
token = create_session_token(user.id, user.auth_sub, settings)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
samesite="lax",
max_age=settings.session_ttl_hours * 3600,
)
return TokenSessionOut(access_token=token, user=UserOut.model_validate(user))
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response) -> Response:
response.delete_cookie(key=SESSION_COOKIE_NAME)
response.status_code = status.HTTP_204_NO_CONTENT
return response