import logging from fastapi import HTTPException from paskia.fastapi.logging import log_permission_denied from paskia.util import permutil, sessionutil logger = logging.getLogger(__name__) class AuthException(HTTPException): """Exception raised during authentication/authorization with metadata for the UI. Attributes: status_code: HTTP status code (401 for auth, 403 for authz) detail: Error message mode: UI mode ('login' or 'reauth') clear_session: Whether to clear the session cookie (True for invalid sessions) metadata: Additional data to pass to the frontend """ def __init__( self, status_code: int, detail: str, mode: str, clear_session: bool = False, **metadata, ): super().__init__(status_code=status_code, detail=detail) self.mode = mode self.clear_session = clear_session self.metadata = metadata async def auth_error_content(exc: AuthException) -> dict: """Generate JSON response content for an AuthException. Returns a dict with detail, mode, and iframe URL for src embedding. """ # Build hash fragment from mode and metadata params = {"mode": exc.mode, **exc.metadata} fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None) iframe_url = f"/auth/restricted/#{fragment}" return { "detail": exc.detail, "auth": { "mode": exc.mode, "iframe": iframe_url, **exc.metadata, }, } async def verify( auth: str | None, perm: list[str], match=permutil.has_all, host: str | None = None, max_age: str | None = None, ): """Validate session token and optional list of required permissions. Returns the session context. Raises AuthException on failure with metadata for UI rendering. """ if not auth: raise AuthException( status_code=401, detail="Authentication required", mode="login", ) ctx = await permutil.session_context(auth, host) if not ctx: raise AuthException( status_code=401, detail="Your session has expired. Please sign in again.", mode="login", clear_session=True, ) # User's theme preference for iframe (only if explicitly set) user_theme = ctx.user.theme if ctx.user.theme else None # Check max_age requirement if specified if max_age: try: if not sessionutil.check_session_age(ctx, max_age): raise AuthException( status_code=401, detail="Additional authentication required", mode="reauth", theme=user_theme, ) except ValueError as e: # Invalid max_age format - log but don't fail the request logger.warning(f"Invalid max_age format '{max_age}': {e}") if not match(ctx, perm): effective_scopes = ( {p.scope for p in (ctx.permissions or [])} if ctx.permissions else set(ctx.role.permissions or []) ) missing = sorted(set(perm) - effective_scopes) log_permission_denied( ctx, perm, missing, require_all=(match == permutil.has_all) ) raise AuthException( status_code=403, mode="forbidden", detail="Permission required", theme=user_theme, ) return ctx