""" Client Service - Business Logic for OIDC Client Management Handles client CRUD operations, secret management, and validation. """ from typing import Optional, Dict, Any, List from app.core.database import db from models import Client from app.repositories import ClientRepository import json import secrets class ClientService: """ Client service - contains ALL business logic for OIDC client management. Following Python Quick Start Guide: - Service layer contains business rules - Uses repository layer for database operations - No HTTP/request handling (that stays in endpoints) """ def __init__(self, db_session=None): """Initialize client service with database session.""" self.db = db_session or db.session self.client_repo = ClientRepository(db_session) def get_all_clients(self) -> List[Client]: """ Get all clients. Business Rules: 1. Return all clients ordered by ID descending Returns: List of Client objects """ return self.client_repo.find_all() def get_client_by_id(self, client_id_pk: int) -> Optional[Client]: """ Get client by primary key ID. Business Rules: 1. Client must exist 2. Return None if not found Args: client_id_pk: Client primary key ID Returns: Client object or None if not found """ return self.client_repo.find_by_id(client_id_pk) def create_client( self, client_name: str, redirect_uris_str: str, allowed_scopes_str: str = 'openid, profile, email', client_id: Optional[str] = None, client_secret: Optional[str] = None ) -> Dict[str, Any]: """ Create a new OIDC client. Business Rules: 1. Client name and redirect URIs are required 2. Client ID must be unique (auto-generate if not provided) 3. Client secret must be secure (auto-generate if not provided) 4. Redirect URIs must be valid (one per line) 5. Allowed scopes must be valid (comma-separated) Args: client_name: Display name for client redirect_uris_str: Newline-separated redirect URIs allowed_scopes_str: Comma-separated allowed scopes client_id: Optional client ID (auto-generated if not provided) client_secret: Optional client secret (auto-generated if not provided) Returns: Dict with 'success' (bool), 'client_id' (if successful), or 'error' """ # Business Rule 1: Required fields if not client_name or not redirect_uris_str: return {'success': False, 'error': 'Client Name and Redirect URIs are required'} # Business Rule 2: Generate or validate client_id if not client_id: client_id = secrets.token_urlsafe(16) # Check uniqueness if self.client_repo.find_by_client_id(client_id): return {'success': False, 'error': 'Client ID already exists'} # Business Rule 3: Generate or validate client_secret if not client_secret: client_secret = secrets.token_urlsafe(32) # Business Rule 4: Parse redirect URIs (newline-separated) redirect_uris = [uri.strip() for uri in redirect_uris_str.splitlines() if uri.strip()] if not redirect_uris: return {'success': False, 'error': 'At least one redirect URI is required'} # Business Rule 5: Parse allowed scopes (comma-separated) allowed_scopes = [scope.strip() for scope in allowed_scopes_str.split(',') if scope.strip()] if not allowed_scopes: return {'success': False, 'error': 'At least one scope is required'} # Create client new_client = Client( client_id=client_id, client_name=client_name, redirect_uris=json.dumps(redirect_uris), allowed_scopes=json.dumps(allowed_scopes) ) new_client.set_client_secret(client_secret) try: self.client_repo.create(new_client) return { 'success': True, 'client_id': new_client.id, 'message': f'Client "{client_name}" created successfully' } except Exception as e: self.client_repo.rollback() return {'success': False, 'error': f'Failed to create client: {str(e)}'} def update_client( self, client_id_pk: int, client_name: str, redirect_uris_str: str, allowed_scopes_str: str, new_client_secret: Optional[str] = None ) -> Dict[str, Any]: """ Update an existing OIDC client. Business Rules: 1. Client must exist 2. Client name and redirect URIs are required 3. Update secret only if provided Args: client_id_pk: Client primary key ID client_name: New client name redirect_uris_str: Newline-separated redirect URIs allowed_scopes_str: Comma-separated allowed scopes new_client_secret: Optional new client secret Returns: Dict with 'success' (bool) and 'message' or 'error' """ # Business Rule 1: Client must exist client = self.client_repo.find_by_id(client_id_pk) if not client: return {'success': False, 'error': 'Client not found'} # Business Rule 2: Required fields if not client_name or not redirect_uris_str: return {'success': False, 'error': 'Client Name and Redirect URIs are required'} # Parse redirect URIs and scopes redirect_uris = [uri.strip() for uri in redirect_uris_str.splitlines() if uri.strip()] allowed_scopes = [scope.strip() for scope in allowed_scopes_str.split(',') if scope.strip()] if not redirect_uris: return {'success': False, 'error': 'At least one redirect URI is required'} # Update client fields client.client_name = client_name client.redirect_uris = json.dumps(redirect_uris) client.allowed_scopes = json.dumps(allowed_scopes) # Business Rule 3: Update secret if provided if new_client_secret: client.set_client_secret(new_client_secret) try: self.client_repo.update(client) return { 'success': True, 'message': f'Client "{client_name}" updated successfully' } except Exception as e: self.client_repo.rollback() return {'success': False, 'error': f'Failed to update client: {str(e)}'} def delete_client(self, client_id_pk: int) -> Dict[str, Any]: """ Delete an OIDC client. Business Rules: 1. Client must exist 2. Permanently remove from database Args: client_id_pk: Client primary key ID Returns: Dict with 'success' (bool) and 'message' or 'error' """ client = self.client_repo.find_by_id(client_id_pk) if not client: return {'success': False, 'error': 'Client not found'} client_name = client.client_name # Save for message try: self.client_repo.delete(client) return {'success': True, 'message': f'Client "{client_name}" deleted permanently'} except Exception as e: self.client_repo.rollback() return {'success': False, 'error': f'Failed to delete client: {str(e)}'} def regenerate_client_id(self, client_id_pk: int) -> Dict[str, Any]: """ Regenerate client ID for an OIDC client. Business Rules: 1. Client must exist 2. Generate new unique client_id 3. Keep all other fields unchanged Args: client_id_pk: Client primary key ID Returns: Dict with 'success' (bool), 'new_client_id', or 'error' """ client = self.client_repo.find_by_id(client_id_pk) if not client: return {'success': False, 'error': 'Client not found'} # Generate new client_id new_client_id = secrets.token_urlsafe(16) # Ensure uniqueness (very unlikely collision, but check anyway) while self.client_repo.find_by_client_id(new_client_id): new_client_id = secrets.token_urlsafe(16) old_client_id = client.client_id client.client_id = new_client_id try: self.client_repo.update(client) return { 'success': True, 'new_client_id': new_client_id, 'message': f'Client ID regenerated from {old_client_id} to {new_client_id}' } except Exception as e: self.client_repo.rollback() return {'success': False, 'error': f'Failed to regenerate client ID: {str(e)}'} def rotate_client_secret(self, client_id_pk: int) -> Dict[str, Any]: """ Rotate (regenerate) client secret for an OIDC client. Business Rules: 1. Client must exist 2. Generate new secure secret 3. Keep all other fields unchanged Args: client_id_pk: Client primary key ID Returns: Dict with 'success' (bool), 'new_client_secret', or 'error' """ client = self.client_repo.find_by_id(client_id_pk) if not client: return {'success': False, 'error': 'Client not found'} # Generate new client secret new_client_secret = secrets.token_urlsafe(32) client.set_client_secret(new_client_secret) try: self.client_repo.update(client) return { 'success': True, 'new_client_secret': new_client_secret, 'message': f'Client secret rotated for {client.client_name}' } except Exception as e: self.client_repo.rollback() return {'success': False, 'error': f'Failed to rotate client secret: {str(e)}'}