"""Sign-in: local accounts and the Authentik OIDC round trip.""" from __future__ import annotations import datetime as dt from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi.responses import RedirectResponse from pydantic import BaseModel from sqlalchemy.orm import Session from .. import auth as auth_mod from ..config import get_settings from ..db import get_db from ..models import User router = APIRouter(prefix="/api/auth", tags=["auth"]) settings = get_settings() class LoginBody(BaseModel): email: str password: str def _set_cookie(response: Response, user: User) -> None: response.set_cookie( auth_mod.SESSION_COOKIE, auth_mod.issue_session(user.id), max_age=settings.session_hours * 3600, httponly=True, samesite="lax", secure=settings.base_url.startswith("https://"), path="/", ) @router.get("/me") def me(user: User | None = Depends(auth_mod.optional_user)): return {"user": user.to_json() if user else None, "config": settings.public_flags()} @router.post("/login") def login(body: LoginBody, response: Response, db: Session = Depends(get_db)): if not settings.auth_local_enabled: raise HTTPException(status.HTTP_403_FORBIDDEN, "Local login is disabled; use SSO.") user = auth_mod.authenticate_local(db, body.email, body.password) if not user: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect email or password") user.last_login = dt.datetime.now(dt.timezone.utc) db.commit() _set_cookie(response, user) return {"user": user.to_json()} @router.post("/logout") def logout(response: Response): response.delete_cookie(auth_mod.SESSION_COOKIE, path="/") return {"ok": True} @router.get("/oidc/start") async def oidc_start(request: Request): if not settings.oidc_enabled: raise HTTPException(status.HTTP_404_NOT_FOUND, "SSO is not enabled") try: meta = await auth_mod.oidc_discovery() except auth_mod.OIDCError as exc: raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc redirect_uri = f"{settings.base_url.rstrip('/')}/api/auth/oidc/callback" state = auth_mod.oidc_state() url = (f"{meta['authorization_endpoint']}?response_type=code" f"&client_id={settings.oidc_client_id}" f"&redirect_uri={redirect_uri}" f"&scope={settings.oidc_scopes.replace(' ', '%20')}" f"&state={state}") response = RedirectResponse(url, status_code=302) response.set_cookie("cx_oidc_state", state, max_age=600, httponly=True, samesite="lax", path="/") return response @router.get("/oidc/callback") async def oidc_callback(request: Request, code: str = "", state: str = "", db: Session = Depends(get_db)): if not settings.oidc_enabled: raise HTTPException(status.HTTP_404_NOT_FOUND, "SSO is not enabled") # The state must match the cookie we set *and* still verify - one guards # against a swapped browser, the other against a forged value. if not code or not state or state != request.cookies.get("cx_oidc_state") \ or not auth_mod.oidc_state_valid(state): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid or expired SSO state") redirect_uri = f"{settings.base_url.rstrip('/')}/api/auth/oidc/callback" try: claims = await auth_mod.oidc_exchange(code, redirect_uri) user = auth_mod.upsert_oidc_user(db, claims) except auth_mod.OIDCError as exc: raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc response = RedirectResponse("/", status_code=302) _set_cookie(response, user) response.delete_cookie("cx_oidc_state", path="/") return response