6c9cd67a08
Replaces the single shared HTTP Basic service-account credential (which caused a production outage from a username mismatch) with per-user login: Keycloak (already running on this VM for gcnm, now also fronted on auth.friessn.de with its own "homekeeper" realm) authenticates the user once via the landing page, FastAPI verifies the OIDC id_token and mints its own signed session JWT as a cookie, and both Shiny apps forward that per-session token as a Bearer credential instead of a static shared one. Authorization is a simple ALLOWED_USERS allowlist; the old auth.users table and bcrypt seeding are gone entirely. Also carries forward the in-progress rootless Podman/Quadlet migration (gitea, homekeeper, podman roles) and fixes a pre-existing bug where each role's handlers were malformed inside tasks/main.yml instead of their own handlers/main.yml, which broke ansible-playbook entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbiCdckkTX2HyAkyi1R39d
78 lines
2.5 KiB
Python
78 lines
2.5 KiB
Python
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 <jwt>' (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}")
|