mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
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]>
1140 lines
35 KiB
Python
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()
|