diff --git a/app/main.py b/app/main.py index 0a06739..90d7701 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,8 @@ import httpx from fastapi import FastAPI, Request, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse +from app import oidc + logging.basicConfig(level=logging.INFO) logger = logging.getLogger("member-dashboard") @@ -30,17 +32,25 @@ app = FastAPI() # --------------------------------------------------------------------------- -# Identity — Cloudron proxyAuth injects the authenticated user's USERNAME. +# Identity — Cloudron OIDC session cookie (username). proxyAuth header is the +# legacy fallback during the transition. # --------------------------------------------------------------------------- def get_user_identity(request: Request) -> str: - """Return the logged-in user's identity (Cloudron username, or email if - Cloudron happens to send one). + """Return the logged-in user's identity (Cloudron username). - Cloudron's proxyAuth injects the USERNAME (e.g. "ntnsndr") via - X-Remote-User, not the email. We pass it through unchanged to the broker, - which resolves username → email (it holds the Cloudron admin token). + Priority: a validated OIDC session cookie (when the oidc addon is active), + then the proxyAuth header (legacy 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", @@ -107,6 +117,8 @@ async def healthz(): async def index(request: Request): identity = get_user_identity(request) if not identity: + if oidc.is_oidc_configured(): + return RedirectResponse("/auth/openid/login", status_code=302) return HTMLResponse( "
Please log in via the dashboard login.
", status_code=401, @@ -154,6 +166,58 @@ async def api_revoke_key(name: str, 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) + 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("Incomplete OIDC callback.
", status_code=400) + if state != expected_state: + return HTMLResponse("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("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 + + # --------------------------------------------------------------------------- # UI (brand-matched, self-contained, no external requests) # --------------------------------------------------------------------------- diff --git a/app/oidc.py b/app/oidc.py new file mode 100644 index 0000000..ecdde66 --- /dev/null +++ b/app/oidc.py @@ -0,0 +1,168 @@ +"""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). +We use `sub` (username) as the identity — the portal's broker accepts it as +`X-Member-User` and resolves username → email. +""" + +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("member-dashboard") + +# Session cookie name + max age (8 hours, roughly a working day). +SESSION_COOKIE = "member_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="member-dashboard-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") + + 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 4c9c51d..a4e2305 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 httpx==0.27.2 jinja2==3.1.4 +authlib==1.3.0 +itsdangerous==2.1.2