9008fbd512
Add OIDC auth for Command Center and runtime GPU endpoint selection pointed at atc-gpu-prod (10.0.10.106), matching what is currently deployed.
230 lines
7.8 KiB
Python
230 lines
7.8 KiB
Python
"""Authentik OIDC login and session cookie for Command Center."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
from urllib.parse import quote, urlencode
|
|
|
|
import httpx
|
|
from authlib.common.security import generate_token
|
|
from authlib.integrations.starlette_client import OAuth
|
|
from authlib.oauth2.rfc7636 import create_s256_code_challenge
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
log = logging.getLogger("atc-agents.auth")
|
|
|
|
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() in ("1", "true", "yes")
|
|
AUTHENTIK_ISSUER = os.getenv(
|
|
"AUTHENTIK_ISSUER",
|
|
"http://atc-mgt01.dell-atc.lan:9000/application/o/command-center/",
|
|
).rstrip("/") + "/"
|
|
AUTHENTIK_CLIENT_ID = os.getenv("AUTHENTIK_CLIENT_ID", "")
|
|
AUTHENTIK_CLIENT_SECRET = os.getenv("AUTHENTIK_CLIENT_SECRET", "")
|
|
AUTHENTIK_REDIRECT_URI = os.getenv(
|
|
"AUTHENTIK_REDIRECT_URI",
|
|
"http://10.0.21.33/auth/callback",
|
|
)
|
|
SESSION_SECRET = os.getenv("SESSION_SECRET", "dev-insecure-change-me")
|
|
SESSION_COOKIE = "cc_session"
|
|
|
|
oauth = OAuth()
|
|
_oauth_ready = False
|
|
|
|
PUBLIC_PREFIXES = (
|
|
"/auth/",
|
|
"/api/health",
|
|
"/api/auth/me",
|
|
)
|
|
|
|
|
|
def is_auth_enabled() -> bool:
|
|
return AUTH_ENABLED
|
|
|
|
|
|
def build_session_user(claims: dict[str, Any]) -> dict[str, Any]:
|
|
name = claims.get("name") or claims.get("preferred_username") or claims.get("email") or "user"
|
|
email = claims.get("email") or ""
|
|
username = claims.get("preferred_username") or claims.get("nickname") or email or str(claims.get("sub") or "user")
|
|
return {
|
|
"sub": claims.get("sub"),
|
|
"email": email,
|
|
"name": name,
|
|
"preferred_username": username,
|
|
}
|
|
|
|
|
|
def get_session_user(request: Request) -> dict[str, Any] | None:
|
|
if not AUTH_ENABLED:
|
|
return {
|
|
"sub": "dev:local",
|
|
"email": "",
|
|
"name": "Dev User",
|
|
"preferred_username": "dev",
|
|
"dev": True,
|
|
}
|
|
user = request.session.get("user")
|
|
return user if isinstance(user, dict) else None
|
|
|
|
|
|
def auth_me_payload(request: Request) -> dict[str, Any]:
|
|
user = get_session_user(request)
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
return {
|
|
"user": user.get("sub"),
|
|
"email": user.get("email"),
|
|
"name": user.get("name"),
|
|
"preferred_username": user.get("preferred_username"),
|
|
"auth_enabled": AUTH_ENABLED,
|
|
}
|
|
|
|
|
|
def _ensure_oauth() -> None:
|
|
global _oauth_ready
|
|
if _oauth_ready or not AUTH_ENABLED:
|
|
return
|
|
if not AUTHENTIK_CLIENT_ID or not AUTHENTIK_CLIENT_SECRET:
|
|
log.warning("AUTH_ENABLED but Authentik client credentials missing")
|
|
return
|
|
meta_url = AUTHENTIK_ISSUER + ".well-known/openid-configuration"
|
|
oauth.register(
|
|
name="authentik",
|
|
client_id=AUTHENTIK_CLIENT_ID,
|
|
client_secret=AUTHENTIK_CLIENT_SECRET,
|
|
server_metadata_url=meta_url,
|
|
client_kwargs={"scope": "openid profile email"},
|
|
)
|
|
_oauth_ready = True
|
|
|
|
|
|
def _is_public_path(path: str) -> bool:
|
|
return any(path == p or path.startswith(p) for p in PUBLIC_PREFIXES)
|
|
|
|
|
|
async def auth_guard_middleware(request: Request, call_next):
|
|
if not AUTH_ENABLED:
|
|
return await call_next(request)
|
|
path = request.url.path
|
|
if _is_public_path(path):
|
|
return await call_next(request)
|
|
if path.startswith("/api/"):
|
|
if get_session_user(request) is None:
|
|
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
|
|
return await call_next(request)
|
|
|
|
|
|
def init_auth_middleware(app: FastAPI) -> None:
|
|
"""Session must wrap auth guard so request.session is populated first."""
|
|
app.middleware("http")(auth_guard_middleware)
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=SESSION_SECRET,
|
|
session_cookie=SESSION_COOKIE,
|
|
max_age=86400 * 7,
|
|
same_site="lax",
|
|
https_only=False,
|
|
)
|
|
|
|
|
|
def setup_auth(app: FastAPI) -> None:
|
|
_ensure_oauth()
|
|
_register_routes(app)
|
|
|
|
|
|
def _token_endpoint() -> str:
|
|
return AUTHENTIK_ISSUER.rstrip("/").rsplit("/application/o/", 1)[0] + "/application/o/token/"
|
|
|
|
|
|
def _userinfo_endpoint() -> str:
|
|
return AUTHENTIK_ISSUER.rstrip("/").rsplit("/application/o/", 1)[0] + "/application/o/userinfo/"
|
|
|
|
|
|
async def _exchange_code_for_userinfo(code: str, code_verifier: str | None) -> dict[str, Any]:
|
|
data = {
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": AUTHENTIK_REDIRECT_URI,
|
|
"client_id": AUTHENTIK_CLIENT_ID,
|
|
"client_secret": AUTHENTIK_CLIENT_SECRET,
|
|
}
|
|
if code_verifier:
|
|
data["code_verifier"] = code_verifier
|
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
tok = await client.post(_token_endpoint(), data=data)
|
|
if tok.status_code >= 400:
|
|
log.warning("Token exchange failed: %s %s", tok.status_code, tok.text[:300])
|
|
tok.raise_for_status()
|
|
payload = tok.json()
|
|
access = payload.get("access_token")
|
|
if not access:
|
|
raise RuntimeError("No access_token in token response")
|
|
ui = await client.get(
|
|
_userinfo_endpoint(),
|
|
headers={"Authorization": f"Bearer {access}"},
|
|
)
|
|
if ui.status_code >= 400:
|
|
log.warning("Userinfo failed: %s %s", ui.status_code, ui.text[:300])
|
|
ui.raise_for_status()
|
|
return ui.json()
|
|
|
|
|
|
def _register_routes(app: FastAPI) -> None:
|
|
@app.get("/api/auth/me")
|
|
async def api_auth_me(request: Request):
|
|
return auth_me_payload(request)
|
|
|
|
@app.get("/auth/login")
|
|
async def auth_login(request: Request):
|
|
if not AUTH_ENABLED:
|
|
return RedirectResponse("/", status_code=302)
|
|
_ensure_oauth()
|
|
if not _oauth_ready:
|
|
raise HTTPException(503, "Authentik not configured")
|
|
code_verifier = generate_token(48)
|
|
request.session["pkce_code_verifier"] = code_verifier
|
|
return await oauth.authentik.authorize_redirect(
|
|
request,
|
|
AUTHENTIK_REDIRECT_URI,
|
|
code_challenge=create_s256_code_challenge(code_verifier),
|
|
code_challenge_method="S256",
|
|
code_verifier=code_verifier,
|
|
)
|
|
|
|
@app.get("/auth/callback")
|
|
async def auth_callback(request: Request):
|
|
if not AUTH_ENABLED:
|
|
return RedirectResponse("/", status_code=302)
|
|
err = request.query_params.get("error")
|
|
if err:
|
|
desc = request.query_params.get("error_description") or err
|
|
log.warning("OIDC provider error: %s — %s", err, desc)
|
|
return RedirectResponse(f"/?error={quote(desc)}", status_code=302)
|
|
code = request.query_params.get("code")
|
|
if not code:
|
|
return RedirectResponse("/?error=missing_code", status_code=302)
|
|
state = request.query_params.get("state")
|
|
if state:
|
|
request.session.pop(f"_state_authentik_{state}", None)
|
|
code_verifier = request.session.pop("pkce_code_verifier", None)
|
|
try:
|
|
userinfo = await _exchange_code_for_userinfo(code, code_verifier)
|
|
except Exception as e:
|
|
log.warning("OIDC callback failed: %s", e)
|
|
return RedirectResponse("/?error=login_failed", status_code=302)
|
|
request.session["user"] = build_session_user(userinfo or {})
|
|
return RedirectResponse("/", status_code=302)
|
|
|
|
@app.get("/auth/logout")
|
|
async def auth_logout(request: Request):
|
|
request.session.clear()
|
|
if not AUTH_ENABLED:
|
|
return RedirectResponse("/", status_code=302)
|
|
end_session = AUTHENTIK_ISSUER + "end-session/"
|
|
post_logout = "http://10.0.21.33/"
|
|
params = urlencode({"post_logout_redirect_uri": post_logout})
|
|
return RedirectResponse(f"{end_session}?{params}", status_code=302)
|