""" Internal API for service-to-service session validation OWASP/NIST Compliant Session Management (Issue #264): - Server-side session state is the authoritative source of truth - Called by tenant-backend on every authenticated request - Returns session status, warning signals, and expiry information """ from fastapi import APIRouter, Depends, HTTPException, status, Header from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session as SyncSession from pydantic import BaseModel from typing import Optional from app.core.database import get_db, get_sync_db from app.services.session_service import SessionService from app.core.config import settings router = APIRouter(prefix="/internal/sessions", tags=["Internal Sessions"]) async def verify_service_auth( x_service_auth: str = Header(None), x_service_name: str = Header(None) ) -> bool: """Verify service-to-service authentication""" if not x_service_auth or not x_service_name: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Service authentication required" ) # Verify service token (in production, use proper service mesh auth) expected_token = settings.SERVICE_AUTH_TOKEN or "internal-service-token" if x_service_auth != expected_token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid service authentication" ) # Verify service is allowed allowed_services = ["resource-cluster", "tenant-backend"] if x_service_name not in allowed_services: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Service {x_service_name} not authorized" ) return True class SessionValidateRequest(BaseModel): """Request body for session validation""" session_token: str class SessionValidateResponse(BaseModel): """Response for session validation""" is_valid: bool expiry_reason: Optional[str] = None # 'idle' or 'absolute' if expired seconds_remaining: Optional[int] = None # Seconds until expiry show_warning: bool = False # True if < 5 minutes remaining user_id: Optional[int] = None tenant_id: Optional[int] = None class SessionRevokeRequest(BaseModel): """Request body for session revocation""" session_token: str reason: str = "logout" class SessionRevokeResponse(BaseModel): """Response for session revocation""" success: bool class SessionRevokeAllRequest(BaseModel): """Request body for revoking all user sessions""" user_id: int reason: str = "password_change" class SessionRevokeAllResponse(BaseModel): """Response for revoking all user sessions""" sessions_revoked: int @router.post("/validate", response_model=SessionValidateResponse) def validate_session( request: SessionValidateRequest, db: SyncSession = Depends(get_sync_db), authorized: bool = Depends(verify_service_auth) ): """ Validate a session and return status information. Called by tenant-backend on every authenticated request. Returns: - is_valid: Whether the session is currently valid - expiry_reason: 'idle' or 'absolute' if expired - seconds_remaining: Time until expiry (min of idle and absolute) - show_warning: True if warning should be shown (< 30 min until absolute timeout) - user_id, tenant_id: Session context if valid """ session_service = SessionService(db) is_valid, expiry_reason, seconds_remaining, session_info = session_service.validate_session( request.session_token ) # If valid, update activity timestamp if is_valid: session_service.update_activity(request.session_token) # Warning is based on ABSOLUTE timeout only (not idle) # because polling keeps idle from expiring when browser is open show_warning = False if is_valid and session_info: absolute_seconds = session_info.get('absolute_seconds_remaining') if absolute_seconds is not None: show_warning = session_service.should_show_warning(absolute_seconds) return SessionValidateResponse( is_valid=is_valid, expiry_reason=expiry_reason, seconds_remaining=seconds_remaining, show_warning=show_warning, user_id=session_info.get('user_id') if session_info else None, tenant_id=session_info.get('tenant_id') if session_info else None ) @router.post("/revoke", response_model=SessionRevokeResponse) def revoke_session( request: SessionRevokeRequest, db: SyncSession = Depends(get_sync_db), authorized: bool = Depends(verify_service_auth) ): """ Revoke a session (e.g., on logout). Called by tenant-backend or control-panel-backend when user logs out. """ session_service = SessionService(db) success = session_service.revoke_session(request.session_token, request.reason) return SessionRevokeResponse(success=success) @router.post("/revoke-all", response_model=SessionRevokeAllResponse) def revoke_all_user_sessions( request: SessionRevokeAllRequest, db: SyncSession = Depends(get_sync_db), authorized: bool = Depends(verify_service_auth) ): """ Revoke all sessions for a user. Called on password change, account lockout, etc. """ session_service = SessionService(db) count = session_service.revoke_all_user_sessions(request.user_id, request.reason) return SessionRevokeAllResponse(sessions_revoked=count) @router.post("/cleanup") def cleanup_expired_sessions( db: SyncSession = Depends(get_sync_db), authorized: bool = Depends(verify_service_auth) ): """ Clean up expired sessions. This endpoint can be called by a scheduled task to mark expired sessions as inactive. Not strictly required (validation does this anyway) but helps keep the database clean. """ session_service = SessionService(db) count = session_service.cleanup_expired_sessions() return {"sessions_cleaned": count}