- Add AI code generation requirements to AGENTS.md - Add module-level docstrings to all 46 Python modules - Add detailed Google-style docstrings to all classes and functions - Remove all inline comments following self-documenting code principle - Include Args, Returns, Raises sections in function docstrings - Add Attributes and Examples sections to class docstrings
186 lines
5.9 KiB
Python
186 lines
5.9 KiB
Python
"""Keycloak authentication client.
|
|
|
|
This module provides a client for Keycloak authentication operations
|
|
including token introspection and user info retrieval.
|
|
"""
|
|
|
|
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.
|
|
|
|
Handles token validation via introspection and user info retrieval.
|
|
Implements token caching to reduce Keycloak server load.
|
|
|
|
Attributes:
|
|
_settings: Application settings with Keycloak config.
|
|
_base_url: Keycloak realm base URL.
|
|
_client_id: OAuth client identifier.
|
|
_client_secret: OAuth client secret.
|
|
_cache: Token info cache for performance.
|
|
_cache_ttl: Cache time-to-live in seconds.
|
|
|
|
Example:
|
|
>>> client = KeycloakAuthClient(settings)
|
|
>>> token_info = await client.introspect_token(token)
|
|
"""
|
|
|
|
def __init__(self, settings: Settings) -> None:
|
|
"""Initialize Keycloak client with settings.
|
|
|
|
Args:
|
|
settings: Application settings with Keycloak configuration.
|
|
"""
|
|
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.
|
|
|
|
Returns:
|
|
Full URL for token introspection endpoint.
|
|
"""
|
|
return f"{self._base_url}/protocol/openid-connect/token/introspection"
|
|
|
|
def _get_userinfo_url(self) -> str:
|
|
"""Get userinfo endpoint URL.
|
|
|
|
Returns:
|
|
Full URL for userinfo endpoint.
|
|
"""
|
|
return f"{self._base_url}/protocol/openid-connect/userinfo"
|
|
|
|
def _get_cached_token(self, token: str) -> TokenInfo | None:
|
|
"""Get cached token info if valid.
|
|
|
|
Args:
|
|
token: Access token string.
|
|
|
|
Returns:
|
|
Cached TokenInfo if valid and not expired, None otherwise.
|
|
"""
|
|
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.
|
|
|
|
Args:
|
|
token: Access token string as cache key.
|
|
token_info: TokenInfo to cache.
|
|
"""
|
|
self._cache[token] = (token_info, time.time())
|
|
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.
|
|
|
|
Validates token with Keycloak server and extracts user information.
|
|
Uses cache to reduce server requests for recently validated tokens.
|
|
|
|
Args:
|
|
token: Access token to validate.
|
|
|
|
Returns:
|
|
TokenInfo with validation result and user claims.
|
|
"""
|
|
cached = self._get_cached_token(token)
|
|
if cached:
|
|
return cached
|
|
|
|
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)
|
|
|
|
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,
|
|
)
|
|
|
|
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.
|
|
|
|
Fetches detailed user profile from Keycloak userinfo endpoint.
|
|
|
|
Args:
|
|
token: Valid access token.
|
|
|
|
Returns:
|
|
KeycloakUser with profile data, or None on error.
|
|
"""
|
|
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 [],
|
|
)
|