import os import time from functools import lru_cache import httpx from fastapi import HTTPException, Request, status from jose import JWTError, jwt COOKIE_NAME = "hk_session" SESSION_JWT_ALG = "HS256" SESSION_TTL_SECONDS = 30 * 24 * 3600 # 30 days — low-traffic personal app, optimize for staying logged in SESSION_JWT_SECRET = os.getenv("SESSION_JWT_SECRET", "") # Full issuer URL including realm, e.g. https://auth.friessn.de/realms/homekeeper (Keycloak) OIDC_ISSUER_URL = os.getenv("OIDC_ISSUER_URL", "").rstrip("/") OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID", "") OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET", "") def _allowed_users() -> set[str]: return {u.strip() for u in os.getenv("ALLOWED_USERS", "").split(",") if u.strip()} def is_allowed_user(username: str) -> bool: return username in _allowed_users() def mint_session_jwt(username: str) -> str: now = int(time.time()) payload = {"sub": username, "iat": now, "exp": now + SESSION_TTL_SECONDS} return jwt.encode(payload, SESSION_JWT_SECRET, algorithm=SESSION_JWT_ALG) def get_current_user(request: Request) -> str: """Accepts either 'Authorization: Bearer ' (used by the Shiny apps) or the hk_session cookie (used by same-origin browser fetches from the landing page).""" token = None auth_header = request.headers.get("authorization", "") if auth_header.lower().startswith("bearer "): token = auth_header[7:] if not token: token = request.cookies.get(COOKIE_NAME) if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") try: payload = jwt.decode(token, SESSION_JWT_SECRET, algorithms=[SESSION_JWT_ALG]) except JWTError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session") return payload["sub"] @lru_cache(maxsize=1) def oidc_config() -> dict: resp = httpx.get(f"{OIDC_ISSUER_URL}/.well-known/openid-configuration", timeout=10) resp.raise_for_status() return resp.json() @lru_cache(maxsize=1) def _oidc_jwks() -> dict: resp = httpx.get(oidc_config()["jwks_uri"], timeout=10) resp.raise_for_status() return resp.json() def verify_oidc_id_token(id_token: str) -> dict: try: return jwt.decode( id_token, _oidc_jwks(), algorithms=["RS256"], audience=OIDC_CLIENT_ID, ) except JWTError as e: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid OIDC token: {e}")