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:
1 parent
30ca7cbcf3
commit
048654d000
3 files changed
+252
-1
No files matched your search
+80
-1
@@ -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(
|
||||
"<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(
|
||||
"<h1>Forbidden</h1><p>This panel is restricted to administrators.</p>",
|
||||
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("<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:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
Reference in new issue
Block a user