"""Keycloak authentication client.""" import time import httpx from app.infrastructure.auth.models import KeycloakUser, TokenInfo from app.infrastructure.config.settings import Settings class KeycloakAuthClient: """Client for Keycloak authentication operations.""" def __init__(self, settings: Settings) -> None: """Initialize Keycloak client with settings.""" self._settings = settings self._base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}" self._client_id = settings.kc.client_id self._client_secret = settings.kc.client_secret self._cache: dict[str, tuple[TokenInfo, float]] = {} self._cache_ttl = settings.kc.token_cache_ttl def _get_introspection_url(self) -> str: """Get token introspection endpoint URL.""" return f"{self._base_url}/protocol/openid-connect/token/introspection" def _get_userinfo_url(self) -> str: """Get userinfo endpoint URL.""" return f"{self._base_url}/protocol/openid-connect/userinfo" def _get_cached_token(self, token: str) -> TokenInfo | None: """Get cached token info if valid.""" if token not in self._cache: return None token_info, cached_at = self._cache[token] if time.time() - cached_at > self._cache_ttl: del self._cache[token] return None return token_info def _cache_token(self, token: str, token_info: TokenInfo) -> None: """Cache token info.""" self._cache[token] = (token_info, time.time()) # Simple cleanup of old entries current_time = time.time() expired_keys = [ k for k, (_, t) in self._cache.items() if current_time - t > self._cache_ttl ] for k in expired_keys: del self._cache[k] async def introspect_token(self, token: str) -> TokenInfo: """Introspect access token using Keycloak.""" # Check cache first cached = self._get_cached_token(token) if cached: return cached # Prepare introspection request data = { "token": token, "client_id": self._client_id, "client_secret": self._client_secret, } try: async with httpx.AsyncClient() as client: response = await client.post( self._get_introspection_url(), data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=10.0, ) response.raise_for_status() result = response.json() except httpx.HTTPError as e: return TokenInfo(active=False, raw_claims={"error": str(e)}) if not result.get("active", False): return TokenInfo(active=False, raw_claims=result) # Extract roles from realm_access or resource_access roles: list[str] = [] realm_access = result.get("realm_access", {}) if isinstance(realm_access, dict): roles.extend(realm_access.get("roles", [])) token_info = TokenInfo( active=True, user_id=result.get("sub", ""), username=result.get("preferred_username", ""), email=result.get("email", ""), roles=roles, raw_claims=result, ) # Cache valid token self._cache_token(token, token_info) return token_info async def get_userinfo(self, token: str) -> KeycloakUser | None: """Get user information from Keycloak using access token.""" try: async with httpx.AsyncClient() as client: response = await client.get( self._get_userinfo_url(), headers={"Authorization": f"Bearer {token}"}, timeout=10.0, ) response.raise_for_status() data = response.json() except httpx.HTTPError: return None return KeycloakUser( id=data.get("sub", ""), username=data.get("preferred_username", ""), email=data.get("email", ""), first_name=data.get("given_name", ""), last_name=data.get("family_name", ""), roles=data.get("realm_access", {}).get("roles", []) if isinstance(data.get("realm_access"), dict) else [], )