Files
oicd/app/services/client_service.py
2025-11-30 00:07:24 +01:00

292 lines
9.9 KiB
Python

"""
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)}'}