diff --git a/app/main.py b/app/main.py index ec5004f..df1565b 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,8 @@ import httpx from fastapi import FastAPI, Request, HTTPException from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from app import oidc + logging.basicConfig(level=logging.INFO) logger = logging.getLogger("admin-panel") @@ -36,6 +38,20 @@ app = FastAPI() def get_identity(request: Request) -> str: + """Return the authenticated user's identity (Cloudron username). + + Priority: a validated OIDC session cookie (when the oidc addon is active), + then the proxyAuth header (legacy, and the fallback during the transition). + """ + # OIDC session cookie (only when the addon is configured). + if oidc.is_oidc_configured(): + token = request.cookies.get(oidc.SESSION_COOKIE) + if token: + identity = oidc.read_session(token) + if identity: + return identity + + # Legacy proxyAuth header fallback. for header in ( "x-remote-user", "x-forwarded-user", @@ -83,7 +99,17 @@ async def healthz(): @app.get("/") async def index(request: Request): - if not is_admin(request): + identity = get_identity(request) + # Unauthenticated + OIDC configured → send to the OIDC login. + if not identity: + if oidc.is_oidc_configured(): + return RedirectResponse("/auth/openid/login", status_code=302) + return HTMLResponse( + "

Forbidden

This panel is restricted to administrators.

", + status_code=403, + ) + # Authenticated but not an admin → forbid. + if identity.lower() not in ADMIN_USERNAMES: return HTMLResponse( "

Forbidden

This panel is restricted to administrators.

", status_code=403, @@ -119,6 +145,59 @@ async def api_set_balance(request: Request): return JSONResponse({"error": e.detail}, status_code=e.status_code) +# --------------------------------------------------------------------------- +# OIDC routes — authorization-code flow against Cloudron's OIDC provider. +# Active only when the `oidc` addon is configured (CLOUDRON_OIDC_* present). +# During the transition the legacy proxyAuth header still works as a fallback. +# --------------------------------------------------------------------------- + +@app.get("/auth/openid/login") +async def oidc_login(request: Request): + if not oidc.is_oidc_configured(): + raise HTTPException(404, "OIDC not configured") + login_url, state, nonce = oidc.build_login_url(request.headers.get("host", "")) + resp = RedirectResponse(login_url, status_code=302) + # Short-lived, HttpOnly, SameSite=Lax cookies to carry state/nonce. + resp.set_cookie("oidc_state", state, max_age=600, httponly=True, samesite="lax") + resp.set_cookie("oidc_nonce", nonce, max_age=600, httponly=True, samesite="lax") + return resp + + +@app.get("/auth/openid/callback") +async def oidc_callback(request: Request): + if not oidc.is_oidc_configured(): + raise HTTPException(404, "OIDC not configured") + code = request.query_params.get("code") + state = request.query_params.get("state") + expected_state = request.cookies.get("oidc_state") + nonce = request.cookies.get("oidc_nonce") + + if not code or not state or not expected_state or not nonce: + return HTMLResponse("

Login failed

Incomplete OIDC callback.

", status_code=400) + if state != expected_state: + return HTMLResponse("

Login failed

State mismatch (possible CSRF).

", status_code=400) + + try: + identity = await oidc.exchange_code(code, request.headers.get("host", ""), nonce) + except ValueError as e: + logger.warning("OIDC login failed: %s", e) + return HTMLResponse("

Login failed

Could not complete sign-in.

", status_code=400) + + session = oidc.write_session(identity) + resp = RedirectResponse("/", status_code=302) + resp.set_cookie(oidc.SESSION_COOKIE, session, max_age=oidc.SESSION_MAX_AGE, httponly=True, samesite="lax") + resp.delete_cookie("oidc_state") + resp.delete_cookie("oidc_nonce") + return resp + + +@app.get("/logout") +async def logout(): + resp = RedirectResponse("/", status_code=302) + resp.delete_cookie(oidc.SESSION_COOKIE) + return resp + + def _esc(s: str) -> str: return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) diff --git a/app/oidc.py b/app/oidc.py new file mode 100644 index 0000000..fbe1808 --- /dev/null +++ b/app/oidc.py @@ -0,0 +1,170 @@ +"""OIDC client for Cloudron's OpenID Connect addon. + +Implements the authorization-code flow against Cloudron's built-in OIDC +provider, so the app no longer depends on the proxyAuth header wall. + +Cloudron exports these env vars (they change on every restart — read per-call, +never cache at import): + + CLOUDRON_OIDC_ISSUER e.g. https://my.inference.coop/openid + CLOUDRON_OIDC_AUTH_ENDPOINT authorization endpoint + CLOUDRON_OIDC_TOKEN_ENDPOINT token endpoint + CLOUDRON_OIDC_KEYS_ENDPOINT JWKS endpoint (RS256/EdDSA keys) + CLOUDRON_OIDC_PROFILE_ENDPOINT userinfo endpoint + CLOUDRON_OIDC_CLIENT_ID client id + CLOUDRON_OIDC_CLIENT_SECRET client secret + CLOUDRON_APP_DOMAIN the app's public domain + +Identity: Cloudron's `sub` claim is the username (the unique user identifier). +The `email` scope adds `email`/`email_verified`; `profile` adds `name`, +`preferred_username`, etc. We use `sub` (username) as the identity, matching +the app's ADMIN_USERNAMES allowlist. +""" + +import os +import secrets as _secrets +import logging +import urllib.parse + +import httpx +from authlib.jose import JsonWebToken, JsonWebKey +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired + +logger = logging.getLogger("admin-panel") + +# Session cookie name + max age (8 hours, roughly a working day). +SESSION_COOKIE = "admin_oidc_session" +SESSION_MAX_AGE = 60 * 60 * 8 + +SCOPES = "openid profile email" + + +def _oidc_env(name: str) -> str: + return os.environ.get(name, "").strip() + + +def is_oidc_configured() -> bool: + """True when Cloudron has injected the OIDC addon env vars.""" + return bool( + _oidc_env("CLOUDRON_OIDC_CLIENT_ID") + and _oidc_env("CLOUDRON_OIDC_CLIENT_SECRET") + and _oidc_env("CLOUDRON_OIDC_AUTH_ENDPOINT") + ) + + +def _session_serializer() -> URLSafeTimedSerializer: + # Derive a stable, secret signing key from the OIDC client secret (already + # a secret the app holds, and stable across restarts). + secret = _oidc_env("CLOUDRON_OIDC_CLIENT_SECRET") or _secrets.token_urlsafe(32) + return URLSafeTimedSerializer(secret, salt="admin-panel-oidc-session") + + +def _redirect_uri(request_host: str) -> str: + domain = _oidc_env("CLOUDRON_APP_DOMAIN") + host = domain or request_host + scheme = "https" + return f"{scheme}://{host}/auth/openid/callback" + + +def build_login_url(request_host: str) -> tuple[str, str, str]: + """Return (login_url, state, nonce). Caller stores state+nonce in cookies.""" + state = _secrets.token_urlsafe(24) + nonce = _secrets.token_urlsafe(24) + params = { + "response_type": "code", + "client_id": _oidc_env("CLOUDRON_OIDC_CLIENT_ID"), + "redirect_uri": _redirect_uri(request_host), + "scope": SCOPES, + "state": state, + "nonce": nonce, + } + url = f"{_oidc_env('CLOUDRON_OIDC_AUTH_ENDPOINT')}?{urllib.parse.urlencode(params)}" + return url, state, nonce + + +async def exchange_code(code: str, request_host: str, nonce: str) -> str: + """Exchange an authorization code for an ID token, returning the username. + + Validates the ID token signature (JWKS), issuer, audience, nonce, and + expiry. Returns the `sub` claim (Cloudron = username) on success. + Raises ValueError on any failure. + """ + issuer = _oidc_env("CLOUDRON_OIDC_ISSUER") + client_id = _oidc_env("CLOUDRON_OIDC_CLIENT_ID") + client_secret = _oidc_env("CLOUDRON_OIDC_CLIENT_SECRET") + token_endpoint = _oidc_env("CLOUDRON_OIDC_TOKEN_ENDPOINT") + keys_endpoint = _oidc_env("CLOUDRON_OIDC_KEYS_ENDPOINT") + + token_resp = None + id_token = None + async with httpx.AsyncClient(timeout=20.0) as client: + # Token exchange (client_secret_post). + token_resp = await client.post( + token_endpoint, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": _redirect_uri(request_host), + "client_id": client_id, + "client_secret": client_secret, + }, + ) + if token_resp.status_code != 200: + logger.warning("OIDC token exchange failed: %s %s", token_resp.status_code, token_resp.text[:200]) + raise ValueError("token exchange failed") + id_token = token_resp.json().get("id_token") + if not id_token: + logger.warning("OIDC token response missing id_token") + raise ValueError("missing id_token") + + # Fetch JWKS. + keys_resp = await client.get(keys_endpoint) + if keys_resp.status_code != 200: + logger.warning("OIDC JWKS fetch failed: %s", keys_resp.status_code) + raise ValueError("jwks fetch failed") + jwks = keys_resp.json() + + # Validate signature + standard claims (exp/nbf) via claims.validate(), + # and check iss/aud/nonce manually (authlib's JWTClaims.validate() only + # handles exp/nbf; issuer/audience/nonce are checked explicitly). + try: + jwk_set = JsonWebKey.import_key_set(jwks) + jwt = JsonWebToken(["RS256", "EdDSA"]) + claims = jwt.decode(id_token, jwk_set) + claims.validate() # validates exp + nbf + # Issuer + audience + nonce (replay protection). + if claims.get("iss") != issuer: + logger.warning("OIDC id_token issuer mismatch: %s", claims.get("iss")) + raise ValueError("issuer mismatch") + if claims.get("aud") != client_id: + logger.warning("OIDC id_token audience mismatch: %s", claims.get("aud")) + raise ValueError("audience mismatch") + if claims.get("nonce") != nonce: + logger.warning("OIDC id_token nonce mismatch") + raise ValueError("nonce mismatch") + except Exception as e: + logger.warning("OIDC id_token validation failed: %s", e) + raise ValueError("id_token validation failed") from e + + username = claims.get("sub") + if not username: + logger.warning("OIDC id_token missing sub claim") + raise ValueError("missing sub claim") + + return str(username) + + +def write_session(identity: str) -> str: + """Create a signed session token for the given identity (username).""" + return _session_serializer().dumps({"identity": identity}) + + +def read_session(token: str) -> str | None: + """Return the identity from a signed session token, or None if invalid.""" + if not token: + return None + try: + data = _session_serializer().loads(token, max_age=SESSION_MAX_AGE) + return data.get("identity") if isinstance(data, dict) else None + except (BadSignature, SignatureExpired): + return None diff --git a/requirements.txt b/requirements.txt index 27a489c..461c4fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 httpx==0.27.2 +authlib==1.3.0 +itsdangerous==2.1.2