import logging from collections.abc import Callable 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', 'reauth' or 'forbidden') clear_session: Whether to clear the session cookie (True for invalid sessions) metadata: Additional data to pass to the frontend ctx: Session context, set only for 403 (session valid, permission missing) """ def __init__( self, status_code: int, detail: str, mode: str, clear_session: bool = False, ctx=None, **metadata, ): super().__init__(status_code=status_code, detail=detail) self.mode = mode self.clear_session = clear_session self.ctx = ctx 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/iframe#{fragment}" return { "detail": exc.detail, "auth": { "mode": exc.mode, "iframe": iframe_url, **exc.metadata, }, } async def verify( auth: str | None, perm: list[str] | list[tuple[str, ...]], match: Callable | None = None, host: str | None = None, max_age: str | None = None, ): """Validate session token and optional list of required permissions. Each perm entry is either a scope pattern or a tuple of alternative scope patterns (OR semantics within a group). All entries must be satisfied (AND semantics). 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 groups = [(p,) if isinstance(p, str) else tuple(p) for p in perm] ok = match(ctx, perm) if match else permutil.has_all_groups(ctx, groups) if not ok: effective_scopes = ( {p.scope for p in (ctx.permissions or [])} if ctx.permissions else set(ctx.role.permissions or []) ) missing = [ "|".join(g) for g in groups if not permutil.group_satisfied(effective_scopes, g) ] log_permission_denied( ctx, ["|".join(g) for g in groups], missing, require_all=True ) raise AuthException( status_code=403, mode="forbidden", detail="Permission required", ctx=ctx, theme=user_theme, ) # 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}") return ctx