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 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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||||
|
|
||||||
|
|||||||
+170
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user