M21: add optional organisation identity

This commit is contained in:
NuklearRabbit
2026-08-10 15:35:25 +02:00
parent 509cb95110
commit c3f1cfc699
38 changed files with 563 additions and 110 deletions
+147 -5
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
import time
import uuid
from authlib.integrations.starlette_client import OAuth, OAuthError # type: ignore[import-untyped]
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -17,12 +19,21 @@ from app.core.security import (
verify_password,
)
from app.models.user import User
from app.schemas import CurrentUser, PasswordLoginRequest
from app.schemas import CurrentUser, OidcStatusOut, PasswordLoginRequest
from app.services.audit import record_audit_event
from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings()
oauth = OAuth()
if settings.oidc_enabled and settings.oidc_issuer_url:
oauth.register(
name="oidc",
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
server_metadata_url=f"{settings.oidc_issuer_url.rstrip('/')}/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
)
def _current_user_out(user: User) -> CurrentUser:
@@ -36,14 +47,21 @@ def _current_user_out(user: User) -> CurrentUser:
def _set_session(response: Response, user: User) -> None:
token = create_session_token(
SessionPayload(
user_id=str(user.id), public_ref=user.public_ref, role=user.role,
display_name=user.display_name, issued_at=int(time.time()),
user_id=str(user.id),
public_ref=user.public_ref,
role=user.role,
display_name=user.display_name,
issued_at=int(time.time()),
session_id=str(uuid.uuid4()),
)
)
response.set_cookie(
settings.session_cookie_name, token, httponly=True, samesite="lax",
secure=settings.session_cookie_secure, max_age=settings.session_ttl_seconds,
settings.session_cookie_name,
token,
httponly=True,
samesite="lax",
secure=settings.session_cookie_secure,
max_age=settings.session_ttl_seconds,
)
@@ -78,6 +96,130 @@ def bootstrap_initial_admin(db: Session) -> None:
db.commit()
def _oidc_configured() -> bool:
return bool(
settings.oidc_enabled
and settings.oidc_issuer_url
and settings.oidc_client_id
and settings.oidc_client_secret
)
def _oidc_redirect_uri() -> str:
return settings.oidc_redirect_uri or (
f"{settings.mobilityops_public_url.rstrip('/')}/api/v1/auth/oidc/callback"
)
def _allowed_oidc_email(email: str) -> bool:
domains = {
value.strip().casefold()
for value in settings.oidc_allowed_email_domains.split(",")
if value.strip()
}
return not domains or email.rsplit("@", 1)[-1].casefold() in domains
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
subject = str(claims.get("sub") or "").strip()
email = str(claims.get("email") or "").strip().lower()
if not subject or not email or claims.get("email_verified") is False:
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
if not _allowed_oidc_email(email):
raise HTTPException(status_code=403, detail="Email domain is not allowed")
provider = settings.oidc_issuer_url.rstrip("/")
user = db.scalar(
select(User).where(
User.identity_provider == provider,
User.external_subject == subject,
)
)
if user is None:
user = db.scalar(select(User).where(User.email == email))
if user is not None and user.external_subject not in (None, subject):
raise HTTPException(status_code=409, detail="Email is linked to another identity")
created = user is None
if created:
if not settings.oidc_auto_provision:
raise HTTPException(status_code=403, detail="OIDC user is not provisioned")
role = settings.oidc_default_role
if role not in {"operations_manager", "rental_employee"}:
role = "rental_employee"
user = User(
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
email=email,
password_hash=None,
display_name=str(claims.get("name") or email),
role=role,
active=True,
)
db.add(user)
db.flush()
assert user is not None
if not user.active:
raise HTTPException(status_code=403, detail="User is inactive")
user.identity_provider = provider
user.external_subject = subject
record_audit_event(
db,
actor_type="system" if created else "user",
actor_id=None if created else user.id,
actor_label=settings.oidc_provider_name,
action="oidc_user_provisioned" if created else "oidc_identity_linked",
entity_type="user",
entity_id=user.id,
metadata={"provider": provider},
)
return user
@router.get("/oidc/status", response_model=OidcStatusOut)
def oidc_status() -> OidcStatusOut:
return OidcStatusOut(
enabled=_oidc_configured(),
provider_name=settings.oidc_provider_name if _oidc_configured() else None,
)
@router.get("/oidc/login")
async def oidc_login(request: Request) -> Response:
if not _oidc_configured():
raise HTTPException(status_code=404, detail="OIDC login is not configured")
client = oauth.create_client("oidc")
if client is None:
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
return await client.authorize_redirect(request, _oidc_redirect_uri())
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Response:
if not _oidc_configured():
raise HTTPException(status_code=404, detail="OIDC login is not configured")
client = oauth.create_client("oidc")
if client is None:
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
try:
token = await client.authorize_access_token(request)
except OAuthError as exc:
raise HTTPException(status_code=401, detail="OIDC authentication failed") from exc
user = _resolve_oidc_user(db, dict(token.get("userinfo") or {}))
response = RedirectResponse(f"{settings.mobilityops_public_url.rstrip('/')}/dashboard")
_set_session(response, user)
record_audit_event(
db,
actor_type="user",
actor_id=user.id,
actor_label=user.display_name,
action="oidc_login",
entity_type="user",
entity_id=user.id,
metadata={"provider": settings.oidc_issuer_url.rstrip("/")},
)
db.commit()
return response
@router.post("/login", response_model=CurrentUser)
def password_login(
body: PasswordLoginRequest,