Phase 1: add OIDC client with proxyAuth header fallback

Replicates the admin-panel OIDC pattern: redirect/callback/token exchange
(client_secret_post) / JWKS validate / signed session cookie. get_user_identity()
prefers the OIDC session and falls back to the proxyAuth header. Adds /logout
route (was previously only a link). No manifest change yet.
This commit is contained in:
inference-bot committed 2026-09-23 14:06:08 -06:00
1 parent c4990ab8f0
commit 91ac38e534
3 files changed
+240 -6

No files matched your search

+70 -6
View File
@@ -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(
"<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>",
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("<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
# ---------------------------------------------------------------------------
# UI (brand-matched, self-contained, no external requests)
# ---------------------------------------------------------------------------
+168
View File
@@ -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
+2
View File
@@ -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