321 lines
10 KiB
Python
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
|