"""Author RAG — SSO / SAML 2.0 Integration.
Allows Enterprise authors (agency plan) to log in with their
company's identity provider (Okta, Azure AD, Google Workspace, etc.)
Flow:
1. Agency admin configures their IdP metadata URL in admin panel
2. Author visits /sso/{agency_slug} → redirected to IdP login
3. IdP posts SAML assertion to /sso/{agency_slug}/acs
4. We verify the assertion, find/create the User, issue a JWT
5. Browser redirected to /admin with JWT in query param
Dependencies:
pip install python3-saml (wraps OpenSSL + lxml)
Routes:
GET /sso/{agency_slug} → SAML AuthnRequest (redirect to IdP)
POST /sso/{agency_slug}/acs → SAML Assertion Consumer Service
GET /sso/{agency_slug}/metadata → SP metadata XML (upload to IdP)
PUT /api/admin/{slug}/sso/config → save IdP metadata URL (admin only)
"""
import structlog
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from pydantic import BaseModel, Field, HttpUrl
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.dependencies import get_admin_author_access, get_db
logger = structlog.get_logger(__name__)
cfg = get_settings()
router = APIRouter()
# In-memory SSO config store (Phase 4: encrypted DB column)
_sso_configs: dict[str, dict] = {}
class SSOConfig(BaseModel):
idp_metadata_url: HttpUrl = Field(..., description="IdP metadata XML URL (Okta/Azure/Google)")
attribute_email: str = Field("email", description="SAML attribute name for user email")
attribute_name: str = Field("displayName", description="SAML attribute name for full name")
auto_provision: bool = Field(True, description="Auto-create user accounts on first SSO login")
# ── Admin config ─────────────────────────────────────────────
@router.put("/{slug}/sso/config")
async def configure_sso(
slug: str, body: SSOConfig,
db: AsyncSession = Depends(get_db),
access=Depends(get_admin_author_access),
):
"""Save IdP metadata URL for SSO."""
author = access.author
from app.models.pricing_plan import PricingPlan
if not access.is_superadmin_proxy:
plan = await db.get(PricingPlan, getattr(author, "stripe_plan_id", "") or "")
if not plan or not getattr(plan, "has_white_label", False):
raise HTTPException(403, "SSO requires Enterprise plan.")
_sso_configs[slug] = body.model_dump()
logger.info("SSO configured", author_id=slug, idp=str(body.idp_metadata_url))
return {"message": "SSO configured. Test by visiting /sso/" + slug}
@router.get("/{slug}/sso/config")
async def get_sso_config(slug: str, access=Depends(get_admin_author_access)):
cfg_data = _sso_configs.get(slug)
if not cfg_data: return {"configured": False}
return {"configured": True, **{k: v for k, v in cfg_data.items() if k != "idp_metadata_url"},
"idp_metadata_url": "***configured***"}
# ── SAML SP endpoints ─────────────────────────────────────────
@router.get("/sso/{agency_slug}", tags=["SSO"])
async def sso_login(agency_slug: str, request: Request):
"""Redirect browser to IdP login page (SAML AuthnRequest)."""
sso_cfg = _sso_configs.get(agency_slug)
if not sso_cfg:
raise HTTPException(404, f"No SSO configured for '{agency_slug}'.")
try:
from onelogin.saml2.auth import OneLogin_Saml2_Auth
saml = _build_saml_auth(request, agency_slug, sso_cfg)
redirect_url = saml.login()
return RedirectResponse(url=redirect_url)
except ImportError:
# python3-saml not installed — return informative error
return HTMLResponse(
"
SSO not available
"
"Install: pip install python3-saml
",
status_code=503,
)
@router.post("/sso/{agency_slug}/acs", tags=["SSO"])
async def sso_acs(
agency_slug: str, request: Request,
SAMLResponse: str = Form(...),
db: AsyncSession = Depends(get_db),
):
"""SAML Assertion Consumer Service — validates assertion, issues JWT."""
sso_cfg = _sso_configs.get(agency_slug)
if not sso_cfg:
raise HTTPException(404, "SSO not configured.")
try:
from onelogin.saml2.auth import OneLogin_Saml2_Auth
saml = _build_saml_auth(request, agency_slug, sso_cfg)
saml.process_response()
errors = saml.get_errors()
if errors:
raise HTTPException(400, f"SAML errors: {', '.join(errors)}")
attrs = saml.get_attributes()
email = _first(attrs.get(sso_cfg.get("attribute_email", "email"), []))
name = _first(attrs.get(sso_cfg.get("attribute_name", "displayName"), []))
if not email:
raise HTTPException(400, "IdP did not return an email attribute.")
# Find or auto-provision user
from sqlalchemy import select
from app.models.user import User
user_r = await db.execute(select(User).where(User.email == email))
user = user_r.scalar_one_or_none()
if not user:
if not sso_cfg.get("auto_provision", True):
raise HTTPException(403, "User not found and auto-provisioning is disabled.")
import bcrypt, secrets
user = User(
email=email, full_name=name or email.split("@")[0],
password_hash=bcrypt.hashpw(secrets.token_bytes(32), bcrypt.gensalt()).decode(),
role="author", is_active=True,
stripe_plan_id=None, # Agency plan inherited from owner
)
db.add(user)
await db.commit()
logger.info("SSO auto-provisioned user", email=email, agency=agency_slug)
from app.services.auth_service import create_access_token
token = create_access_token({"sub": user.id, "email": user.email, "sso": agency_slug})
# Redirect to admin with token in URL fragment (never hits server logs)
return RedirectResponse(
url=f"{cfg.FRONTEND_URL}/admin?token={token}",
status_code=302,
)
except ImportError:
raise HTTPException(503, "python3-saml not installed. Run: pip install python3-saml")
@router.get("/sso/{agency_slug}/metadata", tags=["SSO"])
async def sso_metadata(agency_slug: str, request: Request):
"""Return SP metadata XML — upload this to your IdP."""
sso_cfg = _sso_configs.get(agency_slug)
if not sso_cfg:
raise HTTPException(404, "SSO not configured.")
try:
from onelogin.saml2.settings import OneLogin_Saml2_Settings
settings = OneLogin_Saml2_Settings(_build_saml_settings(request, agency_slug, sso_cfg))
metadata, errors = settings.get_sp_metadata()
if errors:
raise HTTPException(500, f"Metadata errors: {errors}")
return Response(content=metadata, media_type="text/xml")
except ImportError:
return Response(
content="python3-saml not installed",
media_type="text/xml", status_code=503,
)
# ── Helpers ───────────────────────────────────────────────────
def _first(lst):
return lst[0] if lst else None
def _build_saml_settings(request: Request, slug: str, sso_cfg: dict) -> dict:
base = str(request.base_url).rstrip("/")
return {
"strict": True,
"debug": cfg.DEBUG if hasattr(cfg, "DEBUG") else False,
"sp": {
"entityId": f"{base}/sso/{slug}/metadata",
"assertionConsumerService": {
"url": f"{base}/sso/{slug}/acs",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
},
"NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
"idp": {
"entityId": str(sso_cfg.get("idp_metadata_url", "")),
"singleSignOnService": {
"url": str(sso_cfg.get("idp_metadata_url", "")),
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
},
},
}
def _build_saml_auth(request: Request, slug: str, sso_cfg: dict):
from onelogin.saml2.auth import OneLogin_Saml2_Auth
req = {
"https": "on" if request.url.scheme == "https" else "off",
"http_host": request.headers.get("host", "localhost"),
"script_name": request.url.path,
"get_data": dict(request.query_params),
"post_data": {},
}
return OneLogin_Saml2_Auth(req, _build_saml_settings(request, slug, sso_cfg))