Files
saas_backend/app/routes/auth/identity_provider.py
T
2026-08-31 20:39:41 -04:00

321 lines
10 KiB
Python

"""Configuring an identity provider, and signing in through one.
Two audiences in one file, deliberately kept apart:
- **Admin** — a workspace administrator managing their own connection. Every
route is workspace-scoped by the actor's own tenant; there is no id in the
path that could name somebody else's provider.
- **Public** — a signed-out visitor beginning a login. These are unauthenticated
by necessity, so they say as little as possible.
"""
from __future__ import annotations
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.config.settings import settings
from app.helper.helpers import get_client_ip
from app.middleware.auth_middleware import User, get_current_user, require_access
from app.models.auth.identity_model import IdentityProvider
from app.models.auth.tenant_model import Tenant
from app.schemas.auth.identity_provider_schema import (
IdentityProviderCreate,
IdentityProviderResponse,
IdentityProviderUpdate,
PublicProvider,
)
from app.services.auth import identity_provider_service as idp
from app.services.system.audit_log_service import AuditLogService
router = APIRouter()
public_router = APIRouter()
def _to_response(provider: IdentityProvider) -> IdentityProviderResponse:
payload = IdentityProviderResponse.model_validate(provider)
payload.client_secret_set = bool(provider.client_secret_enc)
return payload
def _own(db: Session, provider_id: uuid.UUID, actor: User) -> IdentityProvider:
"""A provider belonging to the actor's workspace, or a 404.
404 rather than 403: another workspace's provider id must not be
distinguishable from one that does not exist.
"""
if not actor.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only a workspace can manage identity providers",
)
provider = (
db.query(IdentityProvider)
.filter(
IdentityProvider.id == provider_id,
IdentityProvider.tenant_id == actor.tenant_id,
)
.first()
)
if provider is None:
raise HTTPException(status_code=404, detail="Identity provider not found")
return provider
@router.get("/", response_model=List[IdentityProviderResponse])
def list_providers(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.sso.read")),
):
providers = (
db.query(IdentityProvider)
.filter(IdentityProvider.tenant_id == current_user.tenant_id)
.order_by(IdentityProvider.name)
.all()
)
return [_to_response(p) for p in providers]
@router.post("/", response_model=IdentityProviderResponse, status_code=201)
def create_provider(
request: Request,
data: IdentityProviderCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.sso.manage")),
):
if not current_user.tenant_id:
raise HTTPException(
status_code=403, detail="Only a workspace can have an identity provider"
)
clash = (
db.query(IdentityProvider)
.filter(
IdentityProvider.tenant_id == current_user.tenant_id,
IdentityProvider.slug == data.slug,
)
.first()
)
if clash:
raise HTTPException(status_code=409, detail="That slug is already in use")
provider = IdentityProvider(
tenant_id=current_user.tenant_id,
**data.model_dump(exclude={"client_secret"}),
)
provider.client_secret = data.client_secret
db.add(provider)
db.flush()
AuditLogService.log(
db=db, module_name="SSO", action_type="CREATE",
entity_id=str(provider.id), entity_name=provider.name,
description=f"Identity provider '{provider.name}' added",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.commit()
db.refresh(provider)
return _to_response(provider)
@router.patch("/{provider_id}", response_model=IdentityProviderResponse)
def update_provider(
request: Request,
provider_id: uuid.UUID,
data: IdentityProviderUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.sso.manage")),
):
provider = _own(db, provider_id, current_user)
changes = data.model_dump(exclude_unset=True)
if "client_secret" in changes:
provider.client_secret = changes.pop("client_secret")
if "issuer" in changes and changes["issuer"] != provider.issuer:
provider.authorization_endpoint = None
provider.token_endpoint = None
provider.jwks_uri = None
provider.discovered_at = None
provider.enabled = False
for key, value in changes.items():
setattr(provider, key, value)
AuditLogService.log(
db=db, module_name="SSO", action_type="UPDATE",
entity_id=str(provider.id), entity_name=provider.name,
description=f"Identity provider '{provider.name}' updated",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
new_values={k: str(v) for k, v in changes.items()},
)
db.commit()
db.refresh(provider)
return _to_response(provider)
@router.post("/{provider_id}/discover", response_model=IdentityProviderResponse)
def refresh_discovery(
provider_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.sso.manage")),
):
"""Re-read the provider's `.well-known` document.
Separate from the update so it can be run on demand — a provider that moves
an endpoint expects clients to re-read this, not to be reconfigured.
"""
provider = idp.discover(db, _own(db, provider_id, current_user))
db.commit()
db.refresh(provider)
return _to_response(provider)
@router.delete("/{provider_id}", status_code=200)
def delete_provider(
request: Request,
provider_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.sso.manage")),
):
"""Removing a provider unlinks every account that signed in through it.
Those accounts remain — they simply have no way in until another provider is
linked or a password is set. Deleting the users instead would turn a
configuration change into data loss.
"""
provider = _own(db, provider_id, current_user)
name = provider.name
AuditLogService.log(
db=db, module_name="SSO", action_type="DELETE",
entity_id=str(provider_id), entity_name=name,
description=f"Identity provider '{name}' removed",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.delete(provider)
db.commit()
return {"message": "Identity provider removed"}
@public_router.get("/{tenant_domain}/providers", response_model=List[PublicProvider])
def public_providers(tenant_domain: str, db: Session = Depends(get_db)):
"""What a signed-out login page may know: a name and a slug.
Not the issuer, not the client id. A visitor should be able to render
"Sign in with Contoso" and learn nothing else about the workspace.
"""
from app.core.tenant_context import unscoped
with unscoped():
tenant = (
db.query(Tenant).filter(Tenant.tenant_domain == tenant_domain).first()
)
if tenant is None:
return []
providers = (
db.query(IdentityProvider)
.filter(
IdentityProvider.tenant_id == tenant.id,
IdentityProvider.enabled.is_(True),
)
.order_by(IdentityProvider.name)
.all()
)
return [PublicProvider(slug=p.slug, name=p.name) for p in providers]
def _redirect_uri() -> str:
return f"{settings.FRONTEND_URL.rstrip('/')}/sso/callback"
@public_router.get("/{tenant_domain}/{slug}/start")
def begin_login(
tenant_domain: str,
slug: str,
redirect_to: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Start a login. Returns where to send the browser."""
from app.core.tenant_context import unscoped
with unscoped():
tenant = (
db.query(Tenant).filter(Tenant.tenant_domain == tenant_domain).first()
)
if tenant is None:
raise HTTPException(status_code=404, detail="Unknown sign-in address")
provider = (
db.query(IdentityProvider)
.filter(
IdentityProvider.tenant_id == tenant.id,
IdentityProvider.slug == slug,
)
.first()
)
if provider is None:
raise HTTPException(status_code=404, detail="Unknown sign-in address")
url = idp.begin_login(db, provider, _redirect_uri(), redirect_to)
db.commit()
return {"authorization_url": url}
@public_router.post("/callback")
def complete_login(
request: Request,
response: Response,
state: str = Query(...),
code: str = Query(...),
db: Session = Depends(get_db),
):
"""Finish a login and issue a session.
Unauthenticated by necessity — this is what turns an external identity into
a session here — so everything it trusts is checked inside
`identity_provider_service.complete_login`: the state, the nonce, PKCE, the
signature, the issuer, the audience and the expiry.
"""
from app.core.tenant_context import unscoped
from app.routes.auth.auth import _cookie_kwargs
from app.services.auth.auth_service import AuthService
with unscoped():
user, redirect_to = idp.complete_login(db, state, code, _redirect_uri())
result = AuthService.issue_session(
db,
user,
user_agent=request.headers.get("user-agent"),
ip_address=get_client_ip(request),
)
cookie_kw = _cookie_kwargs()
response.set_cookie(key="access_token", value=result["access_token"], **cookie_kw)
response.set_cookie(key="refresh_token", value=result["refresh_token"], **cookie_kw)
result["redirect_to"] = redirect_to
return result