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 from app.api.deps import get_current_user, get_db from app.core.config import get_settings from app.core.ratelimit import FailedAttemptLimiter from app.core.security import ( SessionPayload, create_session_token, hash_password, read_session_token, verify_password, ) from app.models.user import User 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() _login_limiter = ( FailedAttemptLimiter( max_failures=settings.login_max_failures, window_seconds=settings.login_failure_window_seconds, ) if settings.login_max_failures > 0 else None ) def _client_key(request: Request) -> str: # The API sits behind the web container's reverse proxy in every documented # deployment; honour the first hop of X-Forwarded-For when present. forwarded = request.headers.get("x-forwarded-for", "") if forwarded: return forwarded.split(",")[0].strip() return request.client.host if request.client else "unknown" 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: return CurrentUser( public_ref=user.public_ref, display_name=user.display_name, role=user.role, # type: ignore[arg-type] ) 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()), 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, ) def bootstrap_initial_admin(db: Session) -> None: """Create or rotate the explicitly configured first manager in operational mode.""" if ( settings.mobilityops_demo_mode or not settings.initial_admin_email or not settings.initial_admin_password ): return email = settings.initial_admin_email.strip().lower() user = db.scalar(select(User).where(User.email == email)) if user is None: user = User( public_ref="USR-ADMIN", email=email, password_hash=hash_password(settings.initial_admin_password), display_name=settings.initial_admin_display_name, role="operations_manager", active=True, ) db.add(user) record_audit_event( db, actor_type="system", actor_label="bootstrap", action="operational_admin_created", entity_type="user", entity_id=user.id, ) 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") if user is not None and claims.get("email_verified") is not True: # Linking an existing local account (possibly the bootstrap admin) purely on an # email match requires the IdP to explicitly assert the address is verified; # an absent claim is treated as unverified. raise HTTPException(status_code=401, detail="Verified OIDC email is required") 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, request: Request, response: Response, db: Session = Depends(get_db), ) -> CurrentUser: if settings.mobilityops_demo_mode: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Password login is unavailable in demo mode", ) limiter_key = _client_key(request) retry_after = _login_limiter.retry_after_seconds(limiter_key) if _login_limiter else 0 if retry_after: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many failed login attempts. Try again later.", headers={"Retry-After": str(retry_after)}, ) user = db.scalar(select(User).where(User.email == body.email.strip().lower())) if user is None or not user.active or not verify_password(body.password, user.password_hash): if _login_limiter: _login_limiter.record_failure(limiter_key) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") if _login_limiter: _login_limiter.reset(limiter_key) _set_session(response, user) record_audit_event( db, actor_type="user", actor_id=user.id, actor_label=user.display_name, action="password_login", entity_type="user", entity_id=user.id, ) db.commit() return _current_user_out(user) @router.get("/session", response_model=CurrentUser) def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser: response.headers["Cache-Control"] = "no-store" return user @router.post("/logout") def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict: token = request.cookies.get(settings.session_cookie_name) payload = read_session_token(token) if token else None if payload is not None and token is not None: revoke_session(db, token, payload) record_audit_event( db, actor_type="user", actor_id=uuid.UUID(payload.user_id), actor_label=payload.display_name, action="logout", entity_type="user", ) db.commit() response.delete_cookie(settings.session_cookie_name) return {"status": "logged_out"}