feature: add oidc login flow

This commit is contained in:
2025-12-28 09:16:16 +01:00
parent 6f49528bbd
commit 3991362e67
9 changed files with 398 additions and 14 deletions

View File

@ -1,14 +1,21 @@
import logging
from typing import List
from fastapi import Body, Depends, FastAPI, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from backend import actions
from backend.actions import ActionError
from backend.auth import authenticate_admin_user, get_current_admin, issue_token, list_manageable_users
from backend.auth import (
authenticate_admin_user,
get_current_admin,
is_authorized_admin,
issue_token,
list_manageable_users,
)
from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
from backend.oidc import OIDCClient, OIDCError
from backend.settings import Settings, get_settings
logging.basicConfig(
@ -21,6 +28,20 @@ app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
templates = Jinja2Templates(directory="backend/templates")
def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient:
if settings.auth_mode != "oidc":
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="OIDC auth not enabled",
)
try:
return OIDCClient(settings)
except OIDCError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
def validate_user(username: str, settings: Settings = Depends(get_settings)) -> str:
allowed = set(list_manageable_users(settings))
if username not in allowed:
@ -33,13 +54,84 @@ def health(settings: Settings = Depends(get_settings)) -> dict:
return {"status": "ok", "dry_run": settings.dry_run}
@app.get("/me")
def whoami(
current_user: str = Depends(get_current_admin),
settings: Settings = Depends(get_settings),
) -> dict:
return {"user": current_user, "auth_mode": settings.auth_mode}
@app.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, settings: Settings = Depends(get_settings)) -> LoginResponse:
def login(
payload: LoginRequest,
response: Response,
settings: Settings = Depends(get_settings),
) -> LoginResponse:
authenticate_admin_user(payload.username, payload.password, settings)
token = issue_token(payload.username, settings)
response.set_cookie(
settings.session_cookie_name,
token,
max_age=settings.token_ttl_seconds,
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
)
return LoginResponse(token=token, expires_in=settings.token_ttl_seconds)
@app.get("/login/oidc/start")
def oidc_start(
settings: Settings = Depends(get_settings),
oidc: OIDCClient = Depends(get_oidc_client),
):
state = oidc.build_state_token()
redirect = RedirectResponse(url=oidc.authorization_url(state))
redirect.set_cookie(
settings.oidc_state_cookie_name,
state,
max_age=300,
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
)
return redirect
@app.get("/login/oidc/callback")
def oidc_callback(
request: Request,
code: str,
state: str,
settings: Settings = Depends(get_settings),
oidc: OIDCClient = Depends(get_oidc_client),
):
stored_state = request.cookies.get(settings.oidc_state_cookie_name, "")
if not oidc.is_state_valid(state, stored_state):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OIDC state")
claims = oidc.exchange_code_for_claims(code)
username = oidc.extract_username(claims)
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing username claim")
if not is_authorized_admin(username, settings):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not authorized to log in")
token = issue_token(username, settings)
redirect = RedirectResponse(url="/")
redirect.set_cookie(
settings.session_cookie_name,
token,
max_age=settings.token_ttl_seconds,
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
)
redirect.delete_cookie(settings.oidc_state_cookie_name)
return redirect
@app.get("/users", response_model=List[UserStatus], dependencies=[Depends(get_current_admin)])
def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]:
logged_in = set(actions.list_logged_in_users())