Phase 1: add OIDC client (redirect/callback/token/session) with proxyAuth header fallback

No manifest change yet — proxyAuth stays active, so get_identity() still works
via header. OIDC routes only activate when CLOUDRON_OIDC_* env vars are present.
Validates id_token signature (JWKS), iss/aud/nonce, exp/nbf.
This commit is contained in:
inference-bot committed 2026-09-23 09:13:37 -06:00
1 parent 30ca7cbcf3
commit 048654d000
3 files changed
+252 -1

No files matched your search

+80 -1
View File
@@ -21,6 +21,8 @@ import httpx
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app import oidc
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("admin-panel") logger = logging.getLogger("admin-panel")
@@ -36,6 +38,20 @@ app = FastAPI()
def get_identity(request: Request) -> str: 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 ( for header in (
"x-remote-user", "x-remote-user",
"x-forwarded-user", "x-forwarded-user",
@@ -83,7 +99,17 @@ async def healthz():
@app.get("/") @app.get("/")
async def index(request: Request): 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(
"<h1>Forbidden</h1><p>This panel is restricted to administrators.</p>",
status_code=403,
)
# Authenticated but not an admin → forbid.
if identity.lower() not in ADMIN_USERNAMES:
return HTMLResponse( return HTMLResponse(
"<h1>Forbidden</h1><p>This panel is restricted to administrators.</p>", "<h1>Forbidden</h1><p>This panel is restricted to administrators.</p>",
status_code=403, status_code=403,
@@ -119,6 +145,59 @@ async def api_set_balance(request: Request):
return JSONResponse({"error": e.detail}, status_code=e.status_code) 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("<h1>Login failed</h1><p>Incomplete OIDC callback.</p>", status_code=400)
if state != expected_state:
return HTMLResponse("<h1>Login failed</h1><p>State mismatch (possible CSRF).</p>", 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("<h1>Login failed</h1><p>Could not complete sign-in.</p>", 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: def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;") return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
+170
View File
@@ -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
+2
View File
@@ -1,3 +1,5 @@
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
httpx==0.27.2 httpx==0.27.2
authlib==1.3.0
itsdangerous==2.1.2