Files
saas_backend/app/services/auth/sso_service.py
T

241 lines
9.0 KiB
Python

import uuid
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.auth.sso_grant_model import SSOGrant
from app.models.auth.module_model import Module
from app.models.auth.module_environment_model import ModuleEnvironment
from app.models.auth.tenant_module_model import TenantModule
from app.models.auth.user_model import User
from app.config.security import security
from app.services.auth.trust_service import TrustService
import json
import time
class SSOService:
@staticmethod
def generate_grant(
db: Session,
user_id: uuid.UUID,
module_id: str,
tenant_id: Optional[uuid.UUID] = None
) -> Dict[str, str]:
"""
Generates a one-time SSO grant code for the specified module.
Resolves the correct environment URL based on tenant/user config.
"""
module = db.query(Module).filter(Module.module_id == module_id).first()
if not module:
raise HTTPException(status_code=404, detail="Module not found")
if module.status != "active":
raise HTTPException(status_code=403, detail="Module is disabled")
environment_slug = "prod"
if tenant_id:
tm = db.query(TenantModule).filter(
TenantModule.tenant_id == tenant_id,
TenantModule.module_id == module.id
).first()
if not tm or not tm.is_active:
raise HTTPException(status_code=403, detail="Tenant does not have access to this module")
if tm.assigned_environment_slug:
environment_slug = tm.assigned_environment_slug
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.slug == environment_slug
).first()
if not env:
env = db.query(ModuleEnvironment).filter(
ModuleEnvironment.module_id == module.id,
ModuleEnvironment.is_default == True
).first()
if not env:
raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.")
grant_code = str(uuid.uuid4().hex)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=60)
redirect_url = f"{env.frontend_base_url}{env.sso_entry_path}?grant={grant_code}"
grant = SSOGrant(
grant_code=grant_code,
user_id=user_id,
module_id=module.id,
tenant_id=tenant_id,
environment_slug=env.slug,
expires_at=expires_at
)
db.add(grant)
db.commit()
db.refresh(grant)
return {
"grant_code": grant.grant_code,
"redirect_url": redirect_url
}
@staticmethod
def generate_signed_payload(
db: Session,
user_id: uuid.UUID,
module_id: str,
tenant_id: Optional[uuid.UUID] = None
) -> Dict[str, Any]:
"""
Generates a signed payload for the client to POST directly to the module backend.
"""
module = db.query(Module).filter(Module.module_id == module_id).first()
if not module or module.status != "active":
raise HTTPException(status_code=404, detail="Module not found or disabled")
environment_slug = "prod"
if tenant_id:
tm = db.query(TenantModule).filter(TenantModule.tenant_id == tenant_id, TenantModule.module_id == module.id).first()
if tm and tm.is_active and tm.assigned_environment_slug:
environment_slug = tm.assigned_environment_slug
env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.slug == environment_slug).first()
if not env:
env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.is_default == True).first()
if not env:
raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
permissions = []
if user.role:
for ra in user.role.role_accesses:
if ra.access:
pass
if user.role.role_module_accesses:
for rma in user.role.role_module_accesses:
if rma.module_access and rma.module_access.module_id == module.id:
permissions.append(rma.module_access.access_code)
timestamp = int(time.time() * 1000)
payload_data = {
"user_id": str(user.id),
"email": user.email,
"tenant_id": str(tenant_id) if tenant_id else None,
"permissions": permissions,
"timestamp": timestamp,
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role.role_name if user.role else None
}
tenant_id_str = str(tenant_id) if tenant_id else ""
canonical_string = f"user_id={user.id}&email={user.email}&tenant_id={tenant_id_str}&timestamp={timestamp}"
try:
signature = TrustService.sign_payload(env, canonical_string)
except ValueError:
raise HTTPException(status_code=500, detail="Module trust configuration error (missing HMAC secret)")
base_url = env.backend_base_url.rstrip('/')
path = env.sso_entry_path if env.sso_entry_path else "/sso/login"
if not path.startswith('/'):
path = '/' + path
target_url = f"{base_url}{path}"
return {
"target_url": target_url,
"payload": payload_data,
"headers": {
"X-App-Id": "saas",
"X-App-Id": module.module_id,
"X-Signature": signature
},
"redirect_url": env.frontend_base_url
}
@staticmethod
def exchange_grant(
db: Session,
grant_code: str,
module_id: str,
environment_slug: str
) -> Dict[str, Any]:
"""
Validates grant and returns a short-lived module-scoped token.
This is called by the Module Backend.
"""
grant = db.query(SSOGrant).filter(SSOGrant.grant_code == grant_code).first()
if not grant:
raise HTTPException(status_code=401, detail="Invalid grant code")
if grant.is_used:
raise HTTPException(status_code=401, detail="Grant code already used")
if grant.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="Grant code expired")
module = db.query(Module).filter(Module.module_id == module_id).first()
if not module or module.id != grant.module_id:
raise HTTPException(status_code=401, detail="Grant invalid for this module")
if grant.environment_slug != environment_slug:
raise HTTPException(status_code=401, detail="Grant invalid for this environment")
user = db.query(User).filter(User.id == grant.user_id).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
if grant.tenant_id:
if user.tenant_id != grant.tenant_id:
raise HTTPException(
status_code=401,
detail="Tenant mismatch for SSO grant"
)
grant.is_used = True
grant.used_at = datetime.now(timezone.utc)
db.commit()
permissions = []
if user.role:
if user.role.role_module_accesses:
for rma in user.role.role_module_accesses:
if rma.module_access and rma.module_access.module_id == module.id:
permissions.append(rma.module_access.access_code)
token_payload = {
"sub": str(user.id),
"email": user.email,
"tenant_id": str(grant.tenant_id) if grant.tenant_id else None,
"module_id": module_id,
"environment": environment_slug,
"permissions": permissions,
"roles": [user.role.role_name] if user.role else []
}
token = security.generate_module_token(token_payload, module_id)
return {
"access_token": token,
"token_type": "bearer",
"expires_in": 900,
"user": {
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name
}
}