diff --git a/backend/app.py b/backend/app.py index 6d1454a..cba7186 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3,15 +3,21 @@ from __future__ import annotations import json import os import time +from functools import wraps from pathlib import Path +import secrets +import string from typing import Any from urllib.error import URLError +from urllib.parse import quote from urllib.parse import urlencode from urllib.request import urlopen -from flask import Flask, jsonify, request, send_from_directory +from flask import Flask, g, jsonify, request, send_from_directory from flask_cors import CORS import httpx +import jwt +from jwt import PyJWKClient app = Flask(__name__, static_folder="../frontend/dist", static_url_path="") @@ -44,6 +50,35 @@ AI_GPU_ANNOTATION = os.getenv("AI_GPU_ANNOTATION", "ai.bstein.dev/gpu") AI_WARM_INTERVAL_SEC = float(os.getenv("AI_WARM_INTERVAL_SEC", "300")) AI_WARM_ENABLED = os.getenv("AI_WARM_ENABLED", "true").lower() in ("1", "true", "yes") +KEYCLOAK_ENABLED = os.getenv("KEYCLOAK_ENABLED", "false").lower() in ("1", "true", "yes") +KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "https://sso.bstein.dev").rstrip("/") +KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "atlas") +KEYCLOAK_CLIENT_ID = os.getenv("KEYCLOAK_CLIENT_ID", "bstein-dev-home") +KEYCLOAK_ISSUER = os.getenv("KEYCLOAK_ISSUER", f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}").rstrip("/") +KEYCLOAK_JWKS_URL = os.getenv("KEYCLOAK_JWKS_URL", f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs").rstrip("/") + +KEYCLOAK_ADMIN_URL = os.getenv("KEYCLOAK_ADMIN_URL", KEYCLOAK_URL).rstrip("/") +KEYCLOAK_ADMIN_CLIENT_ID = os.getenv("KEYCLOAK_ADMIN_CLIENT_ID", "") +KEYCLOAK_ADMIN_CLIENT_SECRET = os.getenv("KEYCLOAK_ADMIN_CLIENT_SECRET", "") +KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM) + +ACCOUNT_ALLOWED_GROUPS = [ + g.strip() + for g in os.getenv("ACCOUNT_ALLOWED_GROUPS", "dev,admin").split(",") + if g.strip() +] + +MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev") +MAILU_SYNC_URL = os.getenv( + "MAILU_SYNC_URL", + "http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events", +).rstrip("/") + +JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/") + +_KEYCLOAK_JWK_CLIENT: PyJWKClient | None = None +_KEYCLOAK_ADMIN_TOKEN: dict[str, Any] = {"token": "", "expires_at": 0.0} + _LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None} @app.route("/api/healthz") @@ -51,6 +86,297 @@ def healthz() -> Any: return jsonify({"ok": True}) +def _normalize_groups(groups: Any) -> list[str]: + if not isinstance(groups, list): + return [] + cleaned: list[str] = [] + for gname in groups: + if not isinstance(gname, str): + continue + cleaned.append(gname.lstrip("/")) + return [gname for gname in cleaned if gname] + + +def _extract_bearer_token() -> str | None: + header = request.headers.get("Authorization", "") + if not header: + return None + parts = header.split(None, 1) + if len(parts) != 2: + return None + scheme, token = parts[0].lower(), parts[1].strip() + if scheme != "bearer" or not token: + return None + return token + + +def _get_jwk_client() -> PyJWKClient: + global _KEYCLOAK_JWK_CLIENT + if _KEYCLOAK_JWK_CLIENT is None: + _KEYCLOAK_JWK_CLIENT = PyJWKClient(KEYCLOAK_JWKS_URL) + return _KEYCLOAK_JWK_CLIENT + + +def _verify_keycloak_token(token: str) -> dict[str, Any]: + if not KEYCLOAK_ENABLED: + raise ValueError("keycloak not enabled") + + signing_key = _get_jwk_client().get_signing_key_from_jwt(token).key + claims = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + options={"verify_aud": False}, + issuer=KEYCLOAK_ISSUER, + ) + + # Ensure this token was minted for our frontend client (defense-in-depth). + azp = claims.get("azp") + aud = claims.get("aud") + aud_list: list[str] = [] + if isinstance(aud, str): + aud_list = [aud] + elif isinstance(aud, list): + aud_list = [a for a in aud if isinstance(a, str)] + + if azp != KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_ID not in aud_list: + raise ValueError("token not issued for this client") + + return claims + + +def require_auth(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + token = _extract_bearer_token() + if not token: + return jsonify({"error": "missing bearer token"}), 401 + try: + claims = _verify_keycloak_token(token) + except Exception: + return jsonify({"error": "invalid token"}), 401 + + g.keycloak_claims = claims + g.keycloak_username = claims.get("preferred_username") or "" + g.keycloak_email = claims.get("email") or "" + g.keycloak_groups = _normalize_groups(claims.get("groups")) + return fn(*args, **kwargs) + + return wrapper + + +@app.route("/api/auth/config", methods=["GET"]) +def auth_config() -> Any: + if not KEYCLOAK_ENABLED: + return jsonify({"enabled": False}) + + issuer = KEYCLOAK_ISSUER + public_origin = request.host_url.rstrip("/") + redirect_uri = quote(f"{public_origin}/", safe="") + login_url = ( + f"{issuer}/protocol/openid-connect/auth" + f"?client_id={quote(KEYCLOAK_CLIENT_ID, safe='')}" + f"&redirect_uri={redirect_uri}" + f"&response_type=code" + f"&scope=openid" + ) + + return jsonify( + { + "enabled": True, + "url": KEYCLOAK_URL, + "realm": KEYCLOAK_REALM, + "client_id": KEYCLOAK_CLIENT_ID, + "login_url": login_url, + "reset_url": login_url, + } + ) + + +def _require_account_access() -> tuple[bool, Any]: + if not KEYCLOAK_ENABLED: + return False, (jsonify({"error": "keycloak not enabled"}), 503) + if not ACCOUNT_ALLOWED_GROUPS: + return True, None + groups = set(getattr(g, "keycloak_groups", []) or []) + if groups.intersection(ACCOUNT_ALLOWED_GROUPS): + return True, None + return False, (jsonify({"error": "forbidden"}), 403) + + +def _kc_admin_ready() -> bool: + return bool(KEYCLOAK_ADMIN_CLIENT_ID and KEYCLOAK_ADMIN_CLIENT_SECRET) + + +def _kc_admin_get_token() -> str: + if not _kc_admin_ready(): + raise RuntimeError("keycloak admin client not configured") + + now = time.time() + cached = _KEYCLOAK_ADMIN_TOKEN.get("token") + expires_at = float(_KEYCLOAK_ADMIN_TOKEN.get("expires_at") or 0.0) + if cached and now < expires_at - 30: + return str(cached) + + token_url = f"{KEYCLOAK_ADMIN_URL}/realms/{KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token" + data = { + "grant_type": "client_credentials", + "client_id": KEYCLOAK_ADMIN_CLIENT_ID, + "client_secret": KEYCLOAK_ADMIN_CLIENT_SECRET, + } + with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.post(token_url, data=data) + resp.raise_for_status() + payload = resp.json() + token = payload.get("access_token") or "" + if not token: + raise RuntimeError("no access_token in response") + expires_in = int(payload.get("expires_in") or 60) + _KEYCLOAK_ADMIN_TOKEN["token"] = token + _KEYCLOAK_ADMIN_TOKEN["expires_at"] = now + expires_in + return token + + +def _kc_admin_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {_kc_admin_get_token()}"} + + +def _kc_admin_find_user(username: str) -> dict[str, Any] | None: + url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users" + params = {"username": username, "exact": "true"} + with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.get(url, params=params, headers=_kc_admin_headers()) + resp.raise_for_status() + users = resp.json() + if not isinstance(users, list) or not users: + return None + # Keycloak returns a list even with exact=true; use first match. + user = users[0] + return user if isinstance(user, dict) else None + + +def _kc_admin_get_user(user_id: str) -> dict[str, Any]: + url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" + with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.get(url, headers=_kc_admin_headers()) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError("unexpected user payload") + return data + + +def _kc_admin_update_user(user_id: str, payload: dict[str, Any]) -> None: + url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" + with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.put(url, headers={**_kc_admin_headers(), "Content-Type": "application/json"}, json=payload) + resp.raise_for_status() + + +def _kc_set_user_attribute(username: str, key: str, value: str) -> None: + user = _kc_admin_find_user(username) + if not user: + raise RuntimeError("user not found") + user_id = user.get("id") or "" + if not user_id: + raise RuntimeError("user id missing") + + full = _kc_admin_get_user(user_id) + attrs = full.get("attributes") or {} + if not isinstance(attrs, dict): + attrs = {} + + attrs[key] = [value] + full["attributes"] = attrs + _kc_admin_update_user(user_id, full) + + +def _random_password(length: int = 32) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def _best_effort_post(url: str) -> None: + if not url: + return + try: + with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: + client.post(url, json={"ts": int(time.time())}) + except Exception: + return + + +@app.route("/api/account/overview", methods=["GET"]) +@require_auth +def account_overview() -> Any: + ok, resp = _require_account_access() + if not ok: + return resp + + username = g.keycloak_username + mailu_username = f"{username}@{MAILU_DOMAIN}" if username else "" + + mailu_status = "ready" + jellyfin_status = "ready" + + if not _kc_admin_ready(): + mailu_status = "server not configured" + jellyfin_status = "server not configured" + + return jsonify( + { + "user": {"username": username, "email": g.keycloak_email, "groups": g.keycloak_groups}, + "mailu": {"status": mailu_status, "username": mailu_username}, + "jellyfin": {"status": jellyfin_status, "username": username}, + } + ) + + +@app.route("/api/account/mailu/rotate", methods=["POST"]) +@require_auth +def account_mailu_rotate() -> Any: + ok, resp = _require_account_access() + if not ok: + return resp + if not _kc_admin_ready(): + return jsonify({"error": "server not configured"}), 503 + + username = g.keycloak_username + if not username: + return jsonify({"error": "missing username"}), 400 + + password = _random_password() + try: + _kc_set_user_attribute(username, "mailu_app_password", password) + except Exception: + return jsonify({"error": "failed to update mail password"}), 502 + + _best_effort_post(MAILU_SYNC_URL) + return jsonify({"password": password}) + + +@app.route("/api/account/jellyfin/rotate", methods=["POST"]) +@require_auth +def account_jellyfin_rotate() -> Any: + ok, resp = _require_account_access() + if not ok: + return resp + if not _kc_admin_ready(): + return jsonify({"error": "server not configured"}), 503 + + username = g.keycloak_username + if not username: + return jsonify({"error": "missing username"}), 400 + + password = _random_password() + try: + _kc_set_user_attribute(username, "jellyfin_app_password", password) + except Exception: + return jsonify({"error": "failed to update jellyfin password"}), 502 + + _best_effort_post(JELLYFIN_SYNC_URL) + return jsonify({"password": password}) + @app.route("/api/monero/get_info") def monero_get_info() -> Any: try: diff --git a/backend/requirements.txt b/backend/requirements.txt index 2ccdf32..fde270a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,3 +2,4 @@ flask==3.0.3 flask-cors==4.0.0 gunicorn==21.2.0 httpx==0.27.2 +PyJWT[crypto]==2.10.1 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6a582d2..7435c86 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,14 +1,15 @@ { "name": "bstein-portfolio", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bstein-portfolio", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "axios": "^1.6.7", + "keycloak-js": "^26.2.2", "mermaid": "^10.9.1", "vue": "^3.4.21", "vue-router": "^4.3.2" @@ -1905,6 +1906,15 @@ "node": ">= 12" } }, + "node_modules/keycloak-js": { + "version": "26.2.2", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.2.tgz", + "integrity": "sha512-ug7pNZ1xNkd7PPkerOJCEU2VnUhS7CYStDOCFJgqCNQ64h53ppxaKrh4iXH0xM8hFu5b1W6e6lsyYWqBMvaQFg==", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ffb55f4..522f56e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "axios": "^1.6.7", + "keycloak-js": "^26.2.2", "mermaid": "^10.9.1", "vue": "^3.4.21", "vue-router": "^4.3.2" diff --git a/frontend/public/silent-check-sso.html b/frontend/public/silent-check-sso.html new file mode 100644 index 0000000..877ef5f --- /dev/null +++ b/frontend/public/silent-check-sso.html @@ -0,0 +1,13 @@ + + + + + + SSO + + + + + diff --git a/frontend/src/auth.js b/frontend/src/auth.js new file mode 100644 index 0000000..7090b2a --- /dev/null +++ b/frontend/src/auth.js @@ -0,0 +1,106 @@ +import Keycloak from "keycloak-js"; +import { reactive } from "vue"; + +export const auth = reactive({ + ready: false, + enabled: false, + authenticated: false, + username: "", + email: "", + groups: [], + loginUrl: "", + resetUrl: "", + token: "", +}); + +let keycloak = null; +let initPromise = null; + +function normalizeGroups(groups) { + if (!Array.isArray(groups)) return []; + return groups + .filter((g) => typeof g === "string") + .map((g) => g.replace(/^\//, "")) + .filter(Boolean); +} + +function updateFromToken() { + const parsed = keycloak?.tokenParsed || {}; + auth.authenticated = Boolean(keycloak?.authenticated); + auth.token = keycloak?.token || ""; + auth.username = parsed.preferred_username || ""; + auth.email = parsed.email || ""; + auth.groups = normalizeGroups(parsed.groups); +} + +export async function initAuth() { + if (initPromise) return initPromise; + + initPromise = (async () => { + try { + const resp = await fetch("/api/auth/config", { headers: { Accept: "application/json" } }); + if (!resp.ok) throw new Error(`auth config ${resp.status}`); + const cfg = await resp.json(); + auth.enabled = Boolean(cfg.enabled); + auth.loginUrl = cfg.login_url || ""; + auth.resetUrl = cfg.reset_url || ""; + + if (!auth.enabled) return; + + keycloak = new Keycloak({ + url: cfg.url, + realm: cfg.realm, + clientId: cfg.client_id, + }); + + const authenticated = await keycloak.init({ + onLoad: "check-sso", + pkceMethod: "S256", + silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, + checkLoginIframe: true, + }); + + auth.authenticated = authenticated; + updateFromToken(); + + keycloak.onAuthSuccess = () => updateFromToken(); + keycloak.onAuthLogout = () => updateFromToken(); + keycloak.onAuthRefreshSuccess = () => updateFromToken(); + keycloak.onTokenExpired = () => { + keycloak + .updateToken(30) + .then(() => updateFromToken()) + .catch(() => updateFromToken()); + }; + + window.setInterval(() => { + if (!keycloak?.authenticated) return; + keycloak.updateToken(60).then(updateFromToken).catch(() => {}); + }, 30_000); + } catch { + auth.enabled = false; + } finally { + auth.ready = true; + } + })(); + + return initPromise; +} + +export async function login(redirectPath = window.location.pathname + window.location.search + window.location.hash) { + if (!keycloak) return; + const redirectUri = new URL(redirectPath, window.location.origin).toString(); + await keycloak.login({ redirectUri }); +} + +export async function logout() { + if (!keycloak) return; + await keycloak.logout({ redirectUri: window.location.origin }); +} + +export async function authFetch(url, options = {}) { + const headers = new Headers(options.headers || {}); + if (auth.token) headers.set("Authorization", `Bearer ${auth.token}`); + return fetch(url, { ...options, headers }); +} + diff --git a/frontend/src/components/TopBar.vue b/frontend/src/components/TopBar.vue index 6248950..d62ea63 100644 --- a/frontend/src/components/TopBar.vue +++ b/frontend/src/components/TopBar.vue @@ -13,18 +13,32 @@ Home About Cloud - Login - Sign Up - Reset + + diff --git a/frontend/src/views/AppsView.vue b/frontend/src/views/AppsView.vue new file mode 100644 index 0000000..7ceace1 --- /dev/null +++ b/frontend/src/views/AppsView.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue new file mode 100644 index 0000000..21d336e --- /dev/null +++ b/frontend/src/views/RequestAccessView.vue @@ -0,0 +1,68 @@ + + + +