feat: Authentik login + switchable GPU prod target
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.
This commit is contained in:
+229
@@ -0,0 +1,229 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user