Files
zooqueenandHanzo Dev 7ef22afa72 fix(iam): call the native routes, not the verbs the server is deleting
23 call sites across hanzo-iam and hanzo-tools-iam reached IAM over the legacy
verb surface — get-user, get-users, get-application(s), get-organization(s),
get-providers, get-role(s), get-user-count, get-user-roles, and the
oauth/access_token spelling. Those routes are being removed: HIP-0111 forbids
them, and a capability with an RFC uses its RFC.

Two things had to change together, which is why this adds routes.py rather than
editing the literals in place. The verb surface answered {status, msg, data} at
HTTP 200 even for a miss; the native surface returns the object at the top level,
uses real status codes, and NAMES its lists — {"users": [...]}, not
{"data": [...]}.

Swapping only the path is the dangerous half-migration, and hanzo-tools-iam shows
why: it did `users = data if isinstance(data, list) else []`, so against a native
response it would have reported ZERO users for a healthy org. Not an error — an
empty list. Both halves changed there.

routes.unwrap reads either shape so a fleet mid-rollout keeps working, and still
RAISES on the legacy error envelope because callers depended on that rather than
on a falsy return. It is deliberately temporary: once every server serves native
only, the envelope branch is dead and goes with it. Only a body whose keys are
exactly the envelope's is unwrapped, so a native row with its own `data` column
survives.

These admin methods had no coverage while they called the verb surface — the
reason migrating them blind was the risk. tests/test_routes.py pins it: no route
may contain "/get-", the token endpoint must be the one discovery advertises,
every list route must declare its key (a missing one unwraps to [] and reads as
empty), and neither client may carry a path literal. Falsified — restore a verb
in the table or in a client and the suite names it.

99 pass. tests/test_fastapi.py cannot collect in this environment
(starlette/httpx version clash, unrelated to this change); hanzo-tools-iam's
suite cannot either (`from mcp.server import FastMCP` against the installed MCP
SDK) — both verified pre-existing by reproducing them with these changes stashed.

Rebased onto upstream's IAM_ROUTE_PREFIX refactor rather than merged: that
refactor centralised the prefix but kept the verbs, so the two changes compose.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 15:41:22 -07:00

1140 lines
35 KiB
Python

"""
Hanzo IAM Client - Sync OAuth2/OIDC client for Hanzo Identity.
Supports multiple organizations.
"""
from __future__ import annotations
import os
import secrets
from typing import TYPE_CHECKING
from urllib.parse import urlencode
import httpx
import jwt
from .config import IAMConfig
from . import routes
from .models import (
IAM_ROUTE_PREFIX,
OIDC_AUTHORIZE_PATH,
OIDC_DISCOVERY_PATH,
OIDC_INTROSPECT_PATH,
OIDC_JWKS_PATH,
OIDC_TOKEN_PATH,
OIDC_USERINFO_PATH,
Application,
JWTClaims,
Organization,
TokenResponse,
User,
UserInfo,
)
if TYPE_CHECKING:
from jwt import PyJWKClient
class IAMClient:
"""
Sync OAuth2/OIDC client for Hanzo IAM.
Supports:
- Authorization code flow
- Client credentials flow (M2M)
- Token validation via JWKS
- Token introspection
- User management
Example:
client = IAMClient(
client_id="my-app",
client_secret="secret",
org=Organization.HANZO,
)
# Get authorization URL
url = client.get_authorization_url(
redirect_uri="https://myapp.com/callback",
state="random-state",
)
# Exchange code for tokens
tokens = client.exchange_code(code, redirect_uri)
# Validate token
claims = client.validate_token(tokens.access_token)
"""
def __init__(
self,
client_id: str | None = None,
client_secret: str | None = None,
org: Organization = Organization.HANZO,
config: IAMConfig | None = None,
bearer_token: str | None = None,
):
"""Initialize IAM client.
Args:
client_id: OAuth2 client ID (or from env)
client_secret: OAuth2 client secret (or from env)
org: Organization enum (determines IAM URL)
config: Full configuration (overrides other args)
bearer_token: Bearer token for admin API auth (alternative to client_id/secret)
"""
if config:
self._config = config
else:
env_config = self._config_from_env(org)
self._config = IAMConfig(
server_url=env_config.server_url,
client_id=client_id or env_config.client_id,
client_secret=client_secret or env_config.client_secret,
organization=env_config.organization,
application=env_config.application,
certificate=env_config.certificate,
)
self._bearer_token = bearer_token
self._http: httpx.Client | None = None
self._jwks_client: PyJWKClient | None = None
self._openid_config: dict | None = None
@staticmethod
def _config_from_env(org: Organization = Organization.HANZO) -> IAMConfig:
"""Read configuration from environment variables.
The canonical contract is ``IAM_*`` only — no upstream-brand
aliases, no per-org fallbacks (see ~/work/hanzo/iam/CLAUDE.md
"Configuration"). The ``org`` argument seeds the default server
URL when ``IAM_ENDPOINT`` is unset and the default organization
name when ``IAM_ORG`` is unset; it does not influence which env
vars are read.
Environment variables:
IAM_ENDPOINT - IAM server URL (defaults to ``org.iam_url``)
IAM_CLIENT_ID - OAuth2 client ID
IAM_CLIENT_SECRET - OAuth2 client secret
IAM_ORG - Organization name (defaults to ``org.value``)
IAM_APP - Application name (defaults to ``app``)
IAM_CERT - JWT verification certificate
"""
return IAMConfig(
server_url=os.getenv("IAM_ENDPOINT", org.iam_url),
client_id=os.getenv("IAM_CLIENT_ID", ""),
client_secret=os.getenv("IAM_CLIENT_SECRET", ""),
organization=os.getenv("IAM_ORG", org.value),
application=os.getenv("IAM_APP", "app"),
certificate=os.getenv("IAM_CERT", ""),
)
@property
def http(self) -> httpx.Client:
"""Get or create HTTP client."""
if self._http is None:
self._http = httpx.Client(
base_url=self._config.server_url.rstrip("/"),
timeout=30.0,
headers={
"User-Agent": "hanzo-iam-python/1.0",
"Content-Type": "application/json",
},
)
return self._http
@property
def config(self) -> IAMConfig:
"""Get client configuration."""
return self._config
# =========================================================================
# OIDC Discovery
# =========================================================================
def get_openid_configuration(self) -> dict:
"""Get OpenID Connect discovery document.
Returns:
OIDC configuration with endpoints, supported features, etc.
"""
if self._openid_config is None:
response = self.http.get(OIDC_DISCOVERY_PATH)
response.raise_for_status()
self._openid_config = response.json()
return self._openid_config
def get_jwks(self) -> dict:
"""Get JSON Web Key Set for token verification.
Returns:
JWKS with public keys for JWT verification.
"""
response = self.http.get(OIDC_JWKS_PATH)
response.raise_for_status()
return response.json()
# =========================================================================
# Authorization Code Flow
# =========================================================================
def get_authorization_url(
self,
redirect_uri: str,
state: str | None = None,
scope: str = "openid profile email",
response_type: str = "code",
nonce: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
) -> str:
"""Build authorization URL for OAuth2 code flow.
Args:
redirect_uri: Callback URL after authorization
state: CSRF protection state (generated if not provided)
scope: OAuth2 scopes (default: openid profile email)
response_type: OAuth2 response type (default: code)
nonce: OIDC nonce for ID token validation
code_challenge: PKCE code challenge
code_challenge_method: PKCE method (S256 or plain)
Returns:
Authorization URL to redirect user to.
"""
if state is None:
state = secrets.token_urlsafe(32)
params = {
"client_id": self._config.client_id,
"redirect_uri": redirect_uri,
"response_type": response_type,
"scope": scope,
"state": state,
}
if nonce:
params["nonce"] = nonce
if code_challenge:
params["code_challenge"] = code_challenge
params["code_challenge_method"] = code_challenge_method or "S256"
base_url = self._config.server_url.rstrip("/")
return f"{base_url}{OIDC_AUTHORIZE_PATH}?{urlencode(params)}"
def exchange_code(
self,
code: str,
redirect_uri: str,
code_verifier: str | None = None,
) -> TokenResponse:
"""Exchange authorization code for tokens.
Args:
code: Authorization code from callback
redirect_uri: Same redirect_uri used in authorization
code_verifier: PKCE code verifier (if using PKCE)
Returns:
TokenResponse with access_token, refresh_token, id_token, etc.
"""
data = {
"grant_type": "authorization_code",
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
"code": code,
"redirect_uri": redirect_uri,
}
if code_verifier:
data["code_verifier"] = code_verifier
response = self.http.post(
OIDC_TOKEN_PATH,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
def refresh_token(self, refresh_token: str) -> TokenResponse:
"""Refresh access token using refresh token.
Args:
refresh_token: Refresh token from previous token response
Returns:
New TokenResponse with fresh tokens.
"""
data = {
"grant_type": "refresh_token",
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
"refresh_token": refresh_token,
}
response = self.http.post(
OIDC_TOKEN_PATH,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
# =========================================================================
# Client Credentials Flow (M2M)
# =========================================================================
def client_credentials(self, scope: str = "openid") -> TokenResponse:
"""Get access token using client credentials (machine-to-machine).
Args:
scope: Requested scopes
Returns:
TokenResponse with access_token.
"""
data = {
"grant_type": "client_credentials",
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
"scope": scope,
}
response = self.http.post(
OIDC_TOKEN_PATH,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
# =========================================================================
# Token Validation
# =========================================================================
def validate_token(
self,
token: str,
verify_exp: bool = True,
verify_aud: bool = True,
) -> JWTClaims:
"""Validate JWT token using JWKS.
Args:
token: JWT access token or ID token
verify_exp: Verify expiration (default: True)
verify_aud: Verify audience matches client_id (default: True)
Returns:
JWTClaims with decoded token claims.
Raises:
jwt.InvalidTokenError: If token is invalid or expired.
"""
# Initialize JWKS client if needed
if self._jwks_client is None:
jwks_url = self._config.jwks_uri
self._jwks_client = jwt.PyJWKClient(jwks_url)
# Get signing key from JWKS
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
# Build verification options
options = {
"verify_exp": verify_exp,
"verify_aud": verify_aud,
}
audience = self._config.client_id if verify_aud else None
# Decode and validate
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256", "ES256"],
audience=audience,
options=options,
)
return JWTClaims.model_validate(claims)
def validate_token_with_cert(
self,
token: str,
verify_exp: bool = True,
) -> JWTClaims:
"""Validate JWT token using configured certificate.
Use this when you have the public certificate configured.
Args:
token: JWT access token or ID token
verify_exp: Verify expiration (default: True)
Returns:
JWTClaims with decoded token claims.
"""
if not self._config.certificate:
raise ValueError(
"Certificate not configured. Use validate_token() with JWKS instead."
)
options = {"verify_exp": verify_exp}
claims = jwt.decode(
token,
self._config.certificate,
algorithms=["RS256"],
options=options,
)
return JWTClaims.model_validate(claims)
def introspect_token(self, token: str) -> dict:
"""Introspect token at IAM server.
Use this for opaque tokens or when you need authoritative validation.
Args:
token: Token to introspect
Returns:
Token metadata including active status, scopes, etc.
"""
data = {
"token": token,
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
}
response = self.http.post(
OIDC_INTROSPECT_PATH,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return response.json()
# =========================================================================
# User Info
# =========================================================================
def get_user_info(self, access_token: str) -> UserInfo:
"""Get user info from OIDC userinfo endpoint.
Args:
access_token: Valid access token
Returns:
UserInfo with user profile data.
"""
response = self.http.get(
OIDC_USERINFO_PATH,
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
return UserInfo.model_validate(response.json())
# =========================================================================
# Admin Auth Helpers
# =========================================================================
def _admin_params(self) -> dict:
"""Return query params for admin API auth (empty if using bearer token)."""
if self._bearer_token:
return {}
return {
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
def _admin_headers(self) -> dict:
"""Return extra headers for admin API auth (Authorization if bearer token)."""
if self._bearer_token:
return {"Authorization": f"Bearer {self._bearer_token}"}
return {}
# =========================================================================
# User Management (IAM Admin API)
# =========================================================================
def get_user(self, user_id: str) -> User:
"""Get user by ID.
Args:
user_id: User ID or username
Returns:
User object with full profile.
"""
params = {
"id": f"{self._config.organization}/{user_id}",
**self._admin_params(),
}
response = self.http.get(
routes.USER,
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user"))
return User.model_validate(routes.unwrap(data))
def get_users(self) -> list[User]:
"""Get all users in organization.
Returns:
List of User objects.
"""
params = {
"owner": self._config.organization,
**self._admin_params(),
}
response = self.http.get(
routes.USERS,
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get users"))
users_data = routes.unwrap(data, "users")
return [User.model_validate(u) for u in users_data]
def get_application(self) -> Application:
"""Get current application configuration.
Returns:
Application configuration including OAuth2 settings.
"""
params = {
"id": f"{self._config.organization}/{self._config.application}",
**self._admin_params(),
}
response = self.http.get(
routes.APPLICATION,
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get application"))
return Application.model_validate(routes.unwrap(data))
# =========================================================================
# User Management (Admin API)
# =========================================================================
def create_user(self, user: User) -> dict:
"""Create a new user.
Args:
user: User object to create.
Returns:
API response data.
"""
return self._modify_user("add-user", user)
def update_user(self, user: User) -> dict:
"""Update an existing user.
Args:
user: User object with updated fields.
Returns:
API response data.
"""
return self._modify_user("update-user", user)
def delete_user(self, user: User) -> dict:
"""Delete a user.
Args:
user: User object to delete.
Returns:
API response data.
"""
return self._modify_user("delete-user", user)
def _modify_user(self, action: str, user: User) -> dict:
"""Modify user via IAM admin API."""
response = self.http.post(
f"{IAM_ROUTE_PREFIX}/{action}",
params=self._admin_params(),
headers=self._admin_headers(),
json=user.model_dump(by_alias=True, exclude_none=True),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", f"Failed to {action}"))
return data
# =========================================================================
# Organization Management
# =========================================================================
def get_organizations(self) -> list[dict]:
"""Get all organizations.
Returns:
List of organization dicts.
"""
params = {
"owner": "admin",
**self._admin_params(),
}
response = self.http.get(
routes.ORGANIZATIONS,
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organizations"))
return routes.unwrap(data, "organizations")
def get_organization(self, name: str) -> dict:
"""Get organization by name.
Args:
name: Organization name.
Returns:
Organization data dict.
"""
params = {
"id": f"admin/{name}",
**self._admin_params(),
}
response = self.http.get(
routes.ORGANIZATION,
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organization"))
return routes.unwrap(data)
# =========================================================================
# Provider Management
# =========================================================================
def get_providers(self, owner: str = "admin") -> list[dict]:
"""Get all providers.
Args:
owner: Owner of providers (default: admin).
Returns:
List of provider dicts.
"""
params = {
"owner": owner,
**self._admin_params(),
}
response = self.http.get(
routes.PROVIDERS,
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get providers"))
return routes.unwrap(data, "providers")
# =========================================================================
# Role Management
# =========================================================================
def get_roles(self, owner: str | None = None) -> list[dict]:
"""Get all roles in organization.
Args:
owner: Organization name (defaults to config.organization).
Returns:
List of role dicts.
"""
params = {
"owner": owner or self._config.organization,
**self._admin_params(),
}
response = self.http.get(
routes.ROLES,
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get roles"))
return routes.unwrap(data, "roles")
# =========================================================================
# Password Management
# =========================================================================
def set_password(
self,
user_owner: str,
user_name: str,
new_password: str,
old_password: str = "",
) -> dict:
"""Set or reset a user's password.
Args:
user_owner: Organization that owns the user.
user_name: Username.
new_password: New password to set.
old_password: Current password (empty for admin reset).
Returns:
API response data.
"""
payload = {
"userOwner": user_owner,
"userName": user_name,
"oldPassword": old_password,
"newPassword": new_password,
}
response = self.http.post(
f"{IAM_ROUTE_PREFIX}/set-password",
params=self._admin_params(),
headers=self._admin_headers(),
json=payload,
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to set password"))
return data
# =========================================================================
# Application Management
# =========================================================================
def get_applications(self, owner: str = "admin") -> list[Application]:
"""Get all applications.
Args:
owner: Owner of applications (default: admin).
Returns:
List of Application objects.
"""
params = {
"owner": owner,
**self._admin_params(),
}
response = self.http.get(
routes.APPLICATIONS,
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get applications"))
apps_data = routes.unwrap(data, "applications")
return [Application.model_validate(a) for a in apps_data]
def update_application(self, application: Application) -> dict:
"""Update an application.
Args:
application: Application object with updated fields.
Returns:
API response data.
"""
response = self.http.post(
f"{IAM_ROUTE_PREFIX}/update-application",
params=self._admin_params(),
headers=self._admin_headers(),
json=application.model_dump(by_alias=True, exclude_none=True),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to update application"))
return data
# =========================================================================
# Login (Masquerade)
# =========================================================================
def login(self, username: str, password: str) -> dict:
"""Login as a user (email/password).
Args:
username: Email or username.
password: User password.
Returns:
Login response with access code or token.
"""
payload = {
"type": "code",
"username": username,
"password": password,
"organization": self._config.organization,
"application": self._config.application,
}
response = self.http.post(f"{IAM_ROUTE_PREFIX}/login", json=payload)
response.raise_for_status()
data = response.json()
if data.get("status") != "ok":
raise ValueError(data.get("msg", "Login failed"))
return data
# =========================================================================
# Lifecycle
# =========================================================================
def close(self) -> None:
"""Close HTTP client and release resources."""
if self._http:
self._http.close()
self._http = None
self._jwks_client = None
def __enter__(self) -> IAMClient:
return self
def __exit__(self, *args) -> None:
self.close()
class AsyncIAMClient:
"""
Async OAuth2/OIDC client for Hanzo IAM.
Same interface as IAMClient but uses async/await.
"""
def __init__(
self,
client_id: str | None = None,
client_secret: str | None = None,
org: Organization = Organization.HANZO,
config: IAMConfig | None = None,
):
"""Initialize async IAM client."""
if config:
self._config = config
else:
env_config = IAMClient._config_from_env(org)
self._config = IAMConfig(
server_url=env_config.server_url,
client_id=client_id or env_config.client_id,
client_secret=client_secret or env_config.client_secret,
organization=env_config.organization,
application=env_config.application,
certificate=env_config.certificate,
)
self._http: httpx.AsyncClient | None = None
self._jwks_client: PyJWKClient | None = None
self._openid_config: dict | None = None
@property
def config(self) -> IAMConfig:
"""Get client configuration."""
return self._config
async def _get_http(self) -> httpx.AsyncClient:
"""Get or create async HTTP client."""
if self._http is None:
self._http = httpx.AsyncClient(
base_url=self._config.server_url.rstrip("/"),
timeout=30.0,
headers={
"User-Agent": "hanzo-iam-python/1.0",
"Content-Type": "application/json",
},
)
return self._http
async def get_openid_configuration(self) -> dict:
"""Get OpenID Connect discovery document."""
if self._openid_config is None:
http = await self._get_http()
response = await http.get(OIDC_DISCOVERY_PATH)
response.raise_for_status()
self._openid_config = response.json()
return self._openid_config
async def get_jwks(self) -> dict:
"""Get JSON Web Key Set."""
http = await self._get_http()
response = await http.get(OIDC_JWKS_PATH)
response.raise_for_status()
return response.json()
def get_authorization_url(
self,
redirect_uri: str,
state: str | None = None,
scope: str = "openid profile email",
response_type: str = "code",
nonce: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
) -> str:
"""Build authorization URL."""
if state is None:
state = secrets.token_urlsafe(32)
params = {
"client_id": self._config.client_id,
"redirect_uri": redirect_uri,
"response_type": response_type,
"scope": scope,
"state": state,
}
if nonce:
params["nonce"] = nonce
if code_challenge:
params["code_challenge"] = code_challenge
params["code_challenge_method"] = code_challenge_method or "S256"
base_url = self._config.server_url.rstrip("/")
return f"{base_url}{OIDC_AUTHORIZE_PATH}?{urlencode(params)}"
async def exchange_code(
self,
code: str,
redirect_uri: str,
code_verifier: str | None = None,
) -> TokenResponse:
"""Exchange authorization code for tokens."""
data = {
"grant_type": "authorization_code",
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
"code": code,
"redirect_uri": redirect_uri,
}
if code_verifier:
data["code_verifier"] = code_verifier
http = await self._get_http()
response = await http.post(
OIDC_TOKEN_PATH,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
async def refresh_token(self, refresh_token: str) -> TokenResponse:
"""Refresh access token."""
data = {
"grant_type": "refresh_token",
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
"refresh_token": refresh_token,
}
http = await self._get_http()
response = await http.post(
OIDC_TOKEN_PATH,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
async def client_credentials(self, scope: str = "openid") -> TokenResponse:
"""Get access token using client credentials."""
data = {
"grant_type": "client_credentials",
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
"scope": scope,
}
http = await self._get_http()
response = await http.post(
OIDC_TOKEN_PATH,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
def validate_token(
self,
token: str,
verify_exp: bool = True,
verify_aud: bool = True,
) -> JWTClaims:
"""Validate JWT token using JWKS (sync operation)."""
if self._jwks_client is None:
jwks_url = self._config.jwks_uri
self._jwks_client = jwt.PyJWKClient(jwks_url)
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
options = {
"verify_exp": verify_exp,
"verify_aud": verify_aud,
}
audience = self._config.client_id if verify_aud else None
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256", "ES256"],
audience=audience,
options=options,
)
return JWTClaims.model_validate(claims)
async def introspect_token(self, token: str) -> dict:
"""Introspect token at IAM server."""
data = {
"token": token,
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
}
http = await self._get_http()
response = await http.post(
OIDC_INTROSPECT_PATH,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return response.json()
async def get_user_info(self, access_token: str) -> UserInfo:
"""Get user info from userinfo endpoint."""
http = await self._get_http()
response = await http.get(
OIDC_USERINFO_PATH,
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
return UserInfo.model_validate(response.json())
async def get_user(self, user_id: str) -> User:
"""Get user by ID."""
params = {
"id": f"{self._config.organization}/{user_id}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.get(routes.USER, params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user"))
return User.model_validate(routes.unwrap(data))
async def get_users(self) -> list[User]:
"""Get all users in organization."""
params = {
"owner": self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.get(routes.USERS, params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get users"))
users_data = routes.unwrap(data, "users")
return [User.model_validate(u) for u in users_data]
async def get_application(self) -> Application:
"""Get current application configuration."""
params = {
"id": f"{self._config.organization}/{self._config.application}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.get(routes.APPLICATION, params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get application"))
return Application.model_validate(routes.unwrap(data))
async def close(self) -> None:
"""Close HTTP client."""
if self._http:
await self._http.aclose()
self._http = None
self._jwks_client = None
async def __aenter__(self) -> AsyncIAMClient:
return self
async def __aexit__(self, *args) -> None:
await self.close()