292 lines
9.9 KiB
Python
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)}'}
|