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 import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from app import oidc
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("member-dashboard") 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: def get_user_identity(request: Request) -> str:
"""Return the logged-in user's identity (Cloudron username, or email if """Return the logged-in user's identity (Cloudron username).
Cloudron happens to send one).
Cloudron's proxyAuth injects the USERNAME (e.g. "ntnsndr") via Priority: a validated OIDC session cookie (when the oidc addon is active),
X-Remote-User, not the email. We pass it through unchanged to the broker, then the proxyAuth header (legacy fallback during the transition).
which resolves username → email (it holds the Cloudron admin token).
""" """
# 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",
@@ -107,6 +117,8 @@ async def healthz():
async def index(request: Request): async def index(request: Request):
identity = get_user_identity(request) identity = get_user_identity(request)
if not identity: if not identity:
if oidc.is_oidc_configured():
return RedirectResponse("/auth/openid/login", status_code=302)
return HTMLResponse( return HTMLResponse(
"<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>", "<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>",
status_code=401, 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) 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) # 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 uvicorn[standard]==0.30.6
httpx==0.27.2 httpx==0.27.2
jinja2==3.1.4 jinja2==3.1.4
authlib==1.3.0
itsdangerous==2.1.2