from __future__ import annotations from typing import Annotated from urllib.parse import urlsplit import httpx import jwt from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import RedirectResponse from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import delete from sqlmodel import Session, select from ..core.config import settings from ..db.models import GatewayUser, UserSession from ..db.session import get_session from ..services.audit_logger import log_event from ..services.oidc_login import ( FLOW_COOKIE, finish_login, login_provider, provider_status, start_login, ) from ..services.user_access import ( PERMISSIONS, ROLES, USER_COOKIE, authorize_request, current_user, digest, issue_session, provision_user, session_status, user_view, ) router = APIRouter(prefix="/api/auth") DatabaseSession = Annotated[Session, Depends(get_session)] @router.get("/session") def get_session_status(request: Request): return session_status(request) @router.get("/providers") def get_providers(): return provider_status() @router.get("/login/{provider}") def login(provider: str, request: Request, db: DatabaseSession): try: effective_provider = login_provider(provider) # A Secure browser cookie cannot follow a LAN/IP login to the public # HTTPS callback. Move to the canonical origin before issuing any flow. if request.headers.get("host", "").casefold() != urlsplit(settings.auth_public_url).netloc.casefold(): response = RedirectResponse(f"{settings.auth_public_url}/api/auth/login/{provider}", status_code=307) response.headers["Cache-Control"] = "no-store" return response url, browser = start_login(db, effective_provider) except (httpx.HTTPError, ValueError, KeyError): return login_failure("failed") except HTTPException as exc: if exc.status_code != 503: raise return login_failure("failed") response = RedirectResponse(url, status_code=302) response.set_cookie(FLOW_COOKIE, browser, max_age=600, httponly=True, secure=True, samesite="lax", path="/api/auth") response.headers["Cache-Control"] = "no-store" return response def login_failure(reason: str) -> RedirectResponse: response = RedirectResponse(f"/?signin_error={reason}", status_code=303) response.headers["Cache-Control"] = "no-store" return response @router.get("/callback/{provider}") def callback(provider: str, request: Request, db: DatabaseSession): state, code = request.query_params.get("state", ""), request.query_params.get("code", "") if not state or not code or request.query_params.get("error"): return login_failure("cancelled" if request.query_params.get("error") else "failed") try: issuer, claims = finish_login(db, provider, state, request.cookies.get(FLOW_COOKIE, ""), code) user = provision_user(db, provider, issuer, claims) opaque = issue_session(db, user) except HTTPException as exc: if exc.status_code not in {401, 403, 503}: raise expired = exc.detail in {"Login expired or browser validation failed", "Login has already been used"} return login_failure("expired" if expired else "failed") except (httpx.HTTPError, jwt.PyJWTError, ValueError, KeyError, TypeError): return login_failure("failed") # Rotate any previous app session on sign-in. previous = request.cookies.get(USER_COOKIE) if previous: db.exec(delete(UserSession).where(UserSession.id == digest(previous))) db.commit() log_event(db, source="manual", identity=f"{user.provider}:{user.subject}", action_id="user.login", target=user.id, target_type="user", risk="low", policy_decision="allowed", reason="Verified identity provider login", result="success") response = RedirectResponse("/", status_code=303) response.set_cookie(USER_COOKIE, opaque, max_age=8 * 60 * 60, httponly=True, secure=True, samesite="lax", path="/") response.delete_cookie(FLOW_COOKIE, path="/api/auth", secure=True, httponly=True, samesite="lax") response.headers["Cache-Control"] = "no-store" return response @router.post("/logout") def logout(request: Request, response: Response, db: DatabaseSession): current_user(request) cookie = request.cookies.get(USER_COOKIE, "") if cookie: db.exec(delete(UserSession).where(UserSession.id == digest(cookie))) db.commit() response.delete_cookie(USER_COOKIE, path="/", secure=True, httponly=True, samesite="lax") return {"authenticated": False, "user": None, "permissions": []} @router.get("/permissions") def permissions(request: Request): authorize_request(request) return [{"id": key, "label": label} for key, label in PERMISSIONS.items()] @router.get("/users") def users(request: Request, db: DatabaseSession): authorize_request(request) return [user_view(user) for user in db.exec(select(GatewayUser).order_by(GatewayUser.email, GatewayUser.id)).all()] class UserAccessUpdate(BaseModel): model_config = ConfigDict(extra="forbid") role: str enabled: bool permissions: list[str] = Field(default_factory=list, max_length=20) @router.patch("/users/{user_id}") def update_user(user_id: str, payload: UserAccessUpdate, request: Request, db: DatabaseSession): admin = authorize_request(request) if payload.role not in ROLES or set(payload.permissions) - set(PERMISSIONS): raise HTTPException(422, "Unknown role or permission") if payload.role != "admin" and "users.manage" in payload.permissions: raise HTTPException(422, "User management requires the admin role") # Serialize last-admin updates, including concurrent changes on SQLite. from sqlalchemy import text db.execute(text("BEGIN IMMEDIATE")) user = db.get(GatewayUser, user_id) if user is None: raise HTTPException(404, "User not found") if user.role == "admin" and user.enabled and (payload.role != "admin" or not payload.enabled): admins = db.exec( select(GatewayUser).where( GatewayUser.role == "admin", GatewayUser.enabled.is_(True) ) ).all() if len(admins) < 2: raise HTTPException(409, "The last enabled administrator cannot be disabled or demoted") before = user_view(user) user.role, user.enabled = payload.role, payload.enabled user.permissions = sorted(set(payload.permissions)) if payload.role == "custom" else [] db.add(user) if not user.enabled: db.exec(delete(UserSession).where(UserSession.user_id == user.id)) db.flush() log_event(db, source="manual", identity=f"{admin.provider}:{admin.subject}", action_id="user.permissions.update", target=user.id, target_type="user", risk="high", policy_decision="allowed", reason="Administrator updated account access", result="success", before_state=before, after_state=user_view(user)) return user_view(user)