Rebuild auth on Keycloak OIDC, fix rootless Ansible deploy

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
This commit is contained in:
friessn
2026-07-13 06:31:36 +00:00
parent 58983e6855
commit 6c9cd67a08
36 changed files with 579 additions and 245 deletions
+66 -56
View File
@@ -1,67 +1,77 @@
import os
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from passlib.context import CryptContext
from sqlalchemy import text
import time
from functools import lru_cache
security = HTTPBasic()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
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 verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def _allowed_users() -> set[str]:
return {u.strip() for u in os.getenv("ALLOWED_USERS", "").split(",") if u.strip()}
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def is_allowed_user(username: str) -> bool:
return username in _allowed_users()
def get_current_user(credentials: HTTPBasicCredentials = Depends(security)):
from app.database import engine
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)
with engine.connect() as conn:
row = conn.execute(
text("SELECT hashed_password FROM auth.users WHERE username = :u"),
{"u": credentials.username},
).fetchone()
if row is None or not verify_password(credentials.password, row[0]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
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,
)
return credentials.username
def seed_users():
"""Seed initial users from INITIAL_USERS env var if users table is empty."""
from app.database import engine
initial_users_env = os.getenv("INITIAL_USERS", "")
if not initial_users_env:
return
with engine.connect() as conn:
count = conn.execute(text("SELECT COUNT(*) FROM auth.users")).scalar()
if count and count > 0:
return
for pair in initial_users_env.split(","):
pair = pair.strip()
if ":" not in pair:
continue
username, password = pair.split(":", 1)
username = username.strip()
password = password.strip()
if not username or not password:
continue
hashed = hash_password(password)
conn.execute(
text(
"INSERT INTO auth.users (username, hashed_password) "
"VALUES (:u, :h) ON CONFLICT (username) DO NOTHING"
),
{"u": username, "h": hashed},
)
conn.commit()
except JWTError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid OIDC token: {e}")