Files
saas_backend/app/services/auth/sso_service.py
T
2026-09-01 16:40:51 +05:30

313 lines
11 KiB
Python

import uuid
import logging
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.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
from app.core.redis import sync_redis_client
import json
import time
from app.services.auth.subscription_entitlement_service import (
SubscriptionEntitlementService,
)
logger = logging.getLogger(__name__)
SIGNATURE_VERSION = "2"
SIGNED_PAYLOAD_TTL_SECONDS = 120
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)
redirect_url = f"{env.frontend_base_url}{env.sso_entry_path}?grant={grant_code}"
grant_data = json.dumps({
"user_id": str(user_id),
"module_id": str(module.id),
"tenant_id": str(tenant_id) if tenant_id else None,
"environment_slug": env.slug,
"created_at": datetime.now(timezone.utc).isoformat(),
})
if not sync_redis_client.client:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="SSO service temporarily unavailable"
)
sync_redis_client.client.setex(f"sso_grant:{grant_code}", 60, grant_data)
return {
"grant_code": 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 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.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
permissions = sorted(
SubscriptionEntitlementService.get_effective_module_access_codes(
db, user, module.id
)
)
if tenant_id and not SubscriptionEntitlementService.is_subscription_live(
SubscriptionEntitlementService.get_tenant(db, tenant_id)
):
raise HTTPException(
status_code=403,
detail="Workspace subscription is not active",
)
timestamp = int(time.time() * 1000)
subscription_details = SubscriptionEntitlementService.get_subscription_summary(
db, tenant_id
)
payload_data = {
"user_id": str(user.id),
"email": user.email,
"tenant_id": str(tenant_id) if tenant_id else None,
"permissions": permissions,
"tenant_name": user.tenant.tenant_name if user.tenant else None,
"subscription": subscription_details,
"timestamp": timestamp,
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role.role_name if user.role else None,
"module_id": module.module_id,
"environment": env.slug,
"nonce": uuid.uuid4().hex,
"issued_at": timestamp,
"expires_at": timestamp + (SIGNED_PAYLOAD_TTL_SECONDS * 1000),
}
canonical_string = json.dumps(
payload_data, sort_keys=True, separators=(",", ":")
)
try:
signature = TrustService.sign_payload(env, canonical_string)
except ValueError as e:
raise HTTPException(status_code=500, detail=f"Module trust configuration error: {str(e)}")
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": module.module_id,
"X-Signature": signature,
"X-Signature-Version": SIGNATURE_VERSION,
},
"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.
Grants are stored in Redis — atomically deleted on exchange (one-time use).
"""
if not sync_redis_client.client:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="SSO service temporarily unavailable"
)
redis_key = f"sso_grant:{grant_code}"
pipe = sync_redis_client.client.pipeline()
pipe.get(redis_key)
pipe.delete(redis_key)
grant_json, _ = pipe.execute()
if not grant_json:
raise HTTPException(status_code=401, detail="Invalid or expired grant code")
grant_data = json.loads(grant_json)
module = db.query(Module).filter(Module.module_id == module_id).first()
if not module or str(module.id) != grant_data["module_id"]:
raise HTTPException(status_code=401, detail="Grant invalid for this module")
if module.status != "active":
raise HTTPException(status_code=403, detail="Module is disabled")
if grant_data["environment_slug"] != environment_slug:
raise HTTPException(status_code=401, detail="Grant invalid for this environment")
user = db.query(User).filter(User.id == grant_data["user_id"]).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
if user.status != "active":
raise HTTPException(status_code=401, detail="User account is not active")
grant_tenant_id = grant_data.get("tenant_id")
if grant_tenant_id:
if str(user.tenant_id) != grant_tenant_id:
raise HTTPException(
status_code=401,
detail="Tenant mismatch for SSO grant"
)
tenant_module = db.query(TenantModule).filter(
TenantModule.tenant_id == user.tenant_id,
TenantModule.module_id == module.id,
).first()
if not tenant_module or not tenant_module.is_active:
raise HTTPException(
status_code=403,
detail="Tenant does not have access to this module",
)
if not SubscriptionEntitlementService.is_subscription_live(
SubscriptionEntitlementService.get_tenant(db, user.tenant_id)
):
raise HTTPException(
status_code=403,
detail="Workspace subscription is not active",
)
permissions = sorted(
SubscriptionEntitlementService.get_effective_module_access_codes(
db, user, module.id
)
)
token_payload = {
"sub": str(user.id),
"email": user.email,
"tenant_id": grant_tenant_id,
"module_id": module_id,
"environment": environment_slug,
"permissions": permissions,
"roles": [user.role.role_name] if user.role else []
}
from app.services.auth import module_identity
if not module_identity.is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Module identity is not configured on this platform",
)
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
}
}