first commit
This commit is contained in:
291
app/services/client_service.py
Normal file
291
app/services/client_service.py
Normal file
@ -0,0 +1,291 @@
|
||||
"""
|
||||
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)}'}
|
||||
Reference in New Issue
Block a user