first commit

This commit is contained in:
2025-11-30 00:07:24 +01:00
commit b5e642aecb
78 changed files with 15162 additions and 0 deletions

View File

@ -0,0 +1,342 @@
"""
OIDC Service - Business Logic for OpenID Connect Flow
Handles authorization, token exchange, and userinfo
"""
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from app.core.database import db
from models import User, Client, AuthorizationCode, AccessToken, AuditLog
import secrets
import jwt
from config import get_config
import os
class OIDCService:
"""
OIDC service - contains business logic for OpenID Connect flows.
Following Python Quick Start Guide:
- Service layer contains business rules
- Orchestrates token generation and validation
"""
def __init__(self, db_session=None):
"""Initialize OIDC service."""
self.db = db_session or db.session
# Load config
env = os.environ.get('FLASK_ENV', 'development')
config = get_config(env)()
self.config = config
def validate_authorization_request(
self,
client_id: str,
redirect_uri: str,
response_type: str,
scope: str = '',
state: str = ''
) -> Dict[str, Any]:
"""
Validate authorization request parameters.
Business Rules:
1. Client must exist and be valid
2. Response type must be 'code'
3. Redirect URI must be in client's allowed list
Args:
client_id: OIDC client ID
redirect_uri: Redirect URI from request
response_type: OAuth response type
scope: Requested scopes
state: Optional state parameter
Returns:
Dict with 'success' (bool) and 'auth_request' data or 'error'
"""
# Business Rule 1: Validate client
client = Client.query.filter_by(client_id=client_id).first()
if not client:
return {'success': False, 'error': 'Invalid client_id'}
# Business Rule 2: Check response type
if response_type != 'code':
return {'success': False, 'error': "Unsupported response_type. Use 'code'"}
# Business Rule 3: Validate redirect_uri
allowed_uris = client.get_redirect_uris()
if not redirect_uri or redirect_uri not in allowed_uris:
return {'success': False, 'error': 'Invalid or missing redirect_uri'}
return {
'success': True,
'auth_request': {
'client_id': client_id,
'redirect_uri': redirect_uri,
'scope': scope,
'state': state
}
}
def authorize_with_credentials(
self,
username: str,
password: str,
client_id: str,
redirect_uri: str,
scope: str,
state: Optional[str] = None
) -> Dict[str, Any]:
"""
Authenticate user and create authorization code.
Business Rules:
1. User must exist and be active
2. Password must be correct
3. Create authorization code for valid user
4. Build redirect URL with code
Args:
username: User's username
password: User's password
client_id: OIDC client ID
redirect_uri: Redirect URI
scope: Requested scopes
state: Optional state parameter
Returns:
Dict with 'success', 'redirect_url' or 'error'
"""
# Business Rule 1 & 2: Authenticate user
user = User.query.filter_by(username=username, is_active=True).first()
if not user or not user.check_password(password):
return {'success': False, 'error': 'Invalid credentials'}
# Business Rule 3: Create authorization code
result = self.create_authorization_code(
client_id=client_id,
user_id=user.id,
redirect_uri=redirect_uri,
scope=scope,
state=state
)
if not result['success']:
return result
# Business Rule 4: Build redirect URL
separator = '&' if '?' in redirect_uri else '?'
redirect_url = f"{redirect_uri}{separator}code={result['code']}"
if state:
redirect_url += f"&state={state}"
return {
'success': True,
'redirect_url': redirect_url
}
def create_authorization_code(
self,
client_id: str,
user_id: int,
redirect_uri: str,
scope: str,
state: Optional[str] = None
) -> Dict[str, Any]:
"""
Create an authorization code for OIDC flow.
Business Rules:
1. Client must exist and be valid
2. Redirect URI must be in client's allowed list
3. User must exist
4. Code expires after configured lifetime
Args:
client_id: OIDC client ID
user_id: Authenticated user ID
redirect_uri: Redirect URI from request
scope: Requested scopes
state: Optional state parameter
Returns:
Dict with 'success', 'code', 'redirect_uri', 'state'
"""
# Business Rule 1: Validate client
client = Client.query.filter_by(client_id=client_id).first()
if not client:
return {'success': False, 'error': 'invalid_client'}
# Business Rule 2: Validate redirect_uri
allowed_uris = client.get_redirect_uris()
if redirect_uri not in allowed_uris:
return {'success': False, 'error': 'invalid_redirect_uri'}
# Business Rule 3: Validate user
user = User.query.get(user_id)
if not user:
return {'success': False, 'error': 'invalid_user'}
# Create authorization code
code = secrets.token_urlsafe(32)
auth_code = AuthorizationCode(
code=code,
client_id=client_id,
user_id=user_id,
redirect_uri=redirect_uri,
scope=scope,
expires_at=datetime.utcnow() + timedelta(seconds=self.config.AUTHORIZATION_CODE_LIFETIME)
)
try:
self.db.add(auth_code)
self.db.commit()
return {
'success': True,
'code': code,
'redirect_uri': redirect_uri,
'state': state
}
except Exception as e:
self.db.rollback()
return {'success': False, 'error': str(e)}
def exchange_code_for_token(
self,
grant_type: str,
code: str,
redirect_uri: str,
client_id: str,
client_secret: str
) -> Dict[str, Any]:
"""
Exchange authorization code for access token.
Business Rules:
1. Grant type must be 'authorization_code'
2. Client must be authenticated
3. Code must be valid and not expired
4. Redirect URI must match
5. Code can only be used once
Args:
grant_type: OAuth grant type
code: Authorization code
redirect_uri: Redirect URI from initial request
client_id: Client ID
client_secret: Client secret
Returns:
Dict with token response or error
"""
# Business Rule 1: Check grant type
if grant_type != 'authorization_code':
return {'error': 'unsupported_grant_type'}
# Business Rule 2: Authenticate client
client = Client.query.filter_by(client_id=client_id).first()
if not client or not client.check_client_secret(client_secret):
return {'error': 'invalid_client'}
# Business Rule 3: Validate code
auth_code = AuthorizationCode.query.filter_by(code=code).first()
if not auth_code or not auth_code.is_valid():
return {'error': 'invalid_grant'}
# Business Rule 4: Check redirect URI
if auth_code.redirect_uri != redirect_uri:
return {'error': 'invalid_grant'}
# Business Rule 5: Mark code as used
auth_code.used = True
# Get user
user = User.query.get(auth_code.user_id)
if not user:
return {'error': 'invalid_grant'}
# Generate tokens
access_token = secrets.token_urlsafe(32)
id_token = self._generate_id_token(user, client_id)
# Store access token
token_record = AccessToken(
token=access_token,
client_id=client_id,
user_id=user.id,
scope=auth_code.scope,
expires_at=datetime.utcnow() + timedelta(seconds=self.config.ACCESS_TOKEN_LIFETIME)
)
try:
self.db.add(token_record)
self.db.commit()
return {
'access_token': access_token,
'token_type': 'Bearer',
'expires_in': self.config.ACCESS_TOKEN_LIFETIME,
'id_token': id_token,
'scope': auth_code.scope
}
except Exception as e:
self.db.rollback()
return {'error': str(e)}
def get_userinfo(self, access_token: str) -> Dict[str, Any]:
"""
Get user information from access token.
Business Rules:
1. Token must be valid
2. Token must not be expired or revoked
Args:
access_token: Bearer access token
Returns:
User information dict or error
"""
# Extract token from Bearer header if needed
if access_token.startswith('Bearer '):
access_token = access_token[7:]
# Business Rule 1 & 2: Validate token
token = AccessToken.query.filter_by(token=access_token).first()
if not token or token.is_expired() or token.revoked:
return {'error': 'invalid_token'}
# Get user info
user = token.user
return user.to_dict()
def _generate_id_token(self, user: User, client_id: str) -> str:
"""
Generate JWT ID token for user.
Args:
user: User object
client_id: Client ID
Returns:
Signed JWT ID token
"""
now = datetime.utcnow()
payload = {
'iss': self.config.OIDC_ISSUER,
'sub': str(user.id),
'aud': client_id,
'exp': now + timedelta(seconds=self.config.ID_TOKEN_LIFETIME),
'iat': now,
'name': user.name,
'email': user.email,
'preferred_username': user.preferred_username,
'role': user.role
}
# Sign with private key
private_key = self.config.OIDC_JWT_PRIVATE_KEY
return jwt.encode(payload, private_key, algorithm='RS256')