feat(auth): sync Keycloak users for authorization
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class KeycloakUserSyncConfigurationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class KeycloakUserSyncRequestError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KeycloakUserSnapshot:
|
||||
keycloak_id: UUID
|
||||
username: str
|
||||
email: str | None
|
||||
service_account_client_id: str | None = None
|
||||
|
||||
|
||||
def _keycloak_admin_users_url(issuer: str) -> str:
|
||||
issuer = issuer.rstrip("/")
|
||||
marker = "/realms/"
|
||||
if not issuer or marker not in issuer:
|
||||
raise KeycloakUserSyncConfigurationError(
|
||||
"Keycloak token issuer is not a complete realm URL"
|
||||
)
|
||||
server_url, realm = issuer.rsplit(marker, 1)
|
||||
if not server_url or not realm:
|
||||
raise KeycloakUserSyncConfigurationError(
|
||||
"Keycloak token issuer is not a complete realm URL"
|
||||
)
|
||||
return f"{server_url}/admin/realms/{quote(realm, safe='')}/users"
|
||||
|
||||
|
||||
async def fetch_keycloak_users(
|
||||
*,
|
||||
issuer: str,
|
||||
bearer_token: str,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> list[KeycloakUserSnapshot]:
|
||||
admin_users_url = _keycloak_admin_users_url(issuer)
|
||||
if not bearer_token:
|
||||
raise KeycloakUserSyncConfigurationError(
|
||||
"Keycloak administrator access token is missing"
|
||||
)
|
||||
owns_client = client is None
|
||||
if client is None:
|
||||
client = httpx.AsyncClient(timeout=15.0)
|
||||
|
||||
try:
|
||||
try:
|
||||
snapshots: list[KeycloakUserSnapshot] = []
|
||||
first = 0
|
||||
page_size = 100
|
||||
while True:
|
||||
response = await client.get(
|
||||
admin_users_url,
|
||||
params={"first": first, "max": page_size},
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {bearer_token}",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise KeycloakUserSyncRequestError(
|
||||
"Keycloak users response is not a list"
|
||||
)
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
keycloak_id = UUID(str(item.get("id", "")))
|
||||
except ValueError:
|
||||
continue
|
||||
username = str(item.get("username") or "").strip()
|
||||
if not username:
|
||||
continue
|
||||
email_value = item.get("email")
|
||||
email = str(email_value).strip() if email_value else None
|
||||
service_account_value = item.get("serviceAccountClientId")
|
||||
snapshots.append(
|
||||
KeycloakUserSnapshot(
|
||||
keycloak_id=keycloak_id,
|
||||
username=username,
|
||||
email=email,
|
||||
service_account_client_id=(
|
||||
str(service_account_value)
|
||||
if service_account_value
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
if len(payload) < page_size:
|
||||
break
|
||||
first += page_size
|
||||
return snapshots
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 403:
|
||||
raise KeycloakUserSyncRequestError(
|
||||
"Keycloak administrator requires query-users and view-users roles"
|
||||
) from exc
|
||||
raise KeycloakUserSyncRequestError(
|
||||
"Keycloak user directory request failed"
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise KeycloakUserSyncRequestError(
|
||||
"Keycloak user directory request failed"
|
||||
) from exc
|
||||
finally:
|
||||
if owns_client:
|
||||
await client.aclose()
|
||||
Reference in New Issue
Block a user