feat: auth/IAM refactor + add hanzo-iam and hanzo-web3 packages

- Rewrite auth.py with full OAuth token management flow
- Rewrite iam.py with comprehensive IAM commands (users, orgs, apps)
- Add hanzo-iam package for IAM SDK
- Add hanzo-web3 package for blockchain SDK
- Update API providers for new auth flow
This commit is contained in:
2026-02-12 20:47:40 -08:00
parent b35a5ca4e0
commit 15cbf35697
22 changed files with 6836 additions and 445 deletions
+155
View File
@@ -0,0 +1,155 @@
# hanzo-iam
Identity and Access Management SDK for the Hanzo ecosystem. Built on Casdoor with organization-aware multi-tenancy.
## Installation
```bash
pip install hanzo-iam
```
With FastAPI integration:
```bash
pip install hanzo-iam[fastapi]
```
With KMS support for certificate management:
```bash
pip install hanzo-iam[kms]
```
## Quick Start
```python
from hanzo_iam import IAMClient, IAMConfig
config = IAMConfig(
endpoint="https://iam.hanzo.ai",
client_id="your-client-id",
client_secret="your-client-secret",
org_name="HANZO",
)
client = IAMClient(config)
# Get authorization URL for user login
auth_url = client.get_auth_url(redirect_uri="https://yourapp.com/callback")
# Exchange code for tokens
tokens = client.get_token(code="auth-code-from-callback")
# Get user info
user = client.get_user_info(access_token=tokens.access_token)
```
## Organizations
| Organization | Endpoint | Description |
|-------------|----------|-------------|
| HANZO | https://iam.hanzo.ai | Hanzo AI platform |
| ZOO | https://iam.zoo.dev | Zoo Labs Foundation |
| LUX | https://iam.lux.network | Lux blockchain network |
| PARS | https://iam.pars.dev | Pars development platform |
## Environment Variables
```bash
# Required
HANZO_IAM_ENDPOINT=https://iam.hanzo.ai
HANZO_IAM_CLIENT_ID=your-client-id
HANZO_IAM_CLIENT_SECRET=your-client-secret
HANZO_IAM_ORG_NAME=HANZO
# Optional
HANZO_IAM_APP_NAME=your-app
HANZO_IAM_CERTIFICATE=path/to/cert.pem
```
## FastAPI Integration
```python
from fastapi import FastAPI, Depends
from hanzo_iam.fastapi import IAMAuth, get_current_user
from hanzo_iam import IAMConfig, User
app = FastAPI()
config = IAMConfig.from_env()
auth = IAMAuth(config)
@app.get("/protected")
async def protected_route(user: User = Depends(auth.require_user)):
return {"user": user.name, "org": user.owner}
@app.get("/optional")
async def optional_auth(user: User | None = Depends(auth.optional_user)):
if user:
return {"message": f"Hello, {user.name}"}
return {"message": "Hello, anonymous"}
```
## Client Credentials Flow
For service-to-service authentication:
```python
from hanzo_iam import IAMClient, IAMConfig
config = IAMConfig(
endpoint="https://iam.hanzo.ai",
client_id="service-client-id",
client_secret="service-client-secret",
org_name="HANZO",
)
client = IAMClient(config)
# Get service token
tokens = client.get_client_credentials_token()
# Use token for API calls
headers = {"Authorization": f"Bearer {tokens.access_token}"}
```
## Async Client
```python
import asyncio
from hanzo_iam import AsyncIAMClient, IAMConfig
async def main():
config = IAMConfig.from_env()
client = AsyncIAMClient(config)
# All methods are async
tokens = await client.get_client_credentials_token()
user = await client.get_user_info(tokens.access_token)
print(f"Authenticated as: {user.name}")
asyncio.run(main())
```
## API Reference
| Method | Description |
|--------|-------------|
| `get_auth_url(redirect_uri, state, scope)` | Generate OAuth authorization URL |
| `get_token(code)` | Exchange authorization code for tokens |
| `refresh_token(refresh_token)` | Refresh access token |
| `get_client_credentials_token()` | Get token via client credentials flow |
| `get_user_info(access_token)` | Get user info from access token |
| `parse_jwt(token)` | Parse and validate JWT claims |
| `get_user(user_id)` | Get user by ID |
| `get_users()` | List all users in organization |
| `create_user(user)` | Create new user |
| `update_user(user)` | Update existing user |
| `delete_user(user_id)` | Delete user |
| `get_organizations()` | List organizations |
| `get_organization(name)` | Get organization by name |
## License
Apache-2.0
+33
View File
@@ -0,0 +1,33 @@
"""Hanzo IAM - Identity and Access Management SDK for Hanzo ecosystem."""
from hanzo_iam.async_client import AsyncCasdoorSDK, AsyncIAMClient
from hanzo_iam.client import CasdoorSDK, IAMClient
from hanzo_iam.models import (
Application,
IAMConfig,
JWTClaims,
Organization,
TokenResponse,
User,
UserInfo,
)
__version__ = "1.0.0"
__all__ = [
# Clients
"IAMClient",
"AsyncIAMClient",
# Config
"IAMConfig",
# Models
"Application",
"JWTClaims",
"Organization",
"TokenResponse",
"User",
"UserInfo",
# Aliases for Casdoor compatibility
"CasdoorSDK",
"AsyncCasdoorSDK",
]
+888
View File
@@ -0,0 +1,888 @@
"""Asynchronous IAM client for Hanzo IAM (Casdoor-based)."""
from __future__ import annotations
import secrets
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
import httpx
import jwt
from hanzo_iam.models import (
Application,
JWTClaims,
Organization,
TokenResponse,
User,
UserInfo,
)
if TYPE_CHECKING:
from jwt import PyJWKClient
from hanzo_iam.config import IAMConfig
class AsyncIAMClient:
"""Asynchronous OAuth2/OIDC client for Hanzo IAM.
Same interface as IAMClient but all I/O methods are async.
Supports:
- Authorization code flow
- Client credentials flow (M2M)
- Token validation via JWKS
- Token introspection
- User management
Example:
async with AsyncIAMClient(
client_id="my-app",
client_secret="secret",
org=Organization.HANZO,
) as client:
# Get tokens
tokens = await client.exchange_code(code, redirect_uri)
# Get user info
user = await client.get_user_info(tokens.access_token)
"""
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.
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)
"""
# Avoid circular import
from hanzo_iam.client import IAMClient
from hanzo_iam.models import IAMConfig as ModelConfig
if config:
self._config = config
else:
env_config = IAMClient._config_from_env(org)
self._config = ModelConfig(
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[str, Any] | None = None
@property
def config(self) -> IAMConfig:
"""Get client configuration."""
return self._config # type: ignore[return-value]
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
# =========================================================================
# OIDC Discovery
# =========================================================================
async def get_openid_configuration(self) -> dict[str, Any]:
"""Get OpenID Connect discovery document.
Returns:
OIDC configuration with endpoints, supported features, etc.
"""
if self._openid_config is None:
http = await self._get_http()
response = await http.get("/.well-known/openid-configuration")
response.raise_for_status()
self._openid_config = response.json()
return self._openid_config
async def get_jwks(self) -> dict[str, Any]:
"""Get JSON Web Key Set for token verification.
Returns:
JWKS with public keys for JWT verification.
"""
http = await self._get_http()
response = await http.get("/.well-known/jwks")
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.
This method is synchronous since it only builds a URL.
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}/login/oauth/authorize?{urlencode(params)}"
async 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
http = await self._get_http()
response = await http.post(
"/api/login/oauth/access_token",
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 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,
}
http = await self._get_http()
response = await http.post(
"/api/login/oauth/refresh_token",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
# =========================================================================
# Client Credentials Flow (M2M)
# =========================================================================
async 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,
}
http = await self._get_http()
response = await http.post(
"/api/login/oauth/access_token",
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.
Note: JWT validation is CPU-bound, so this remains synchronous.
The JWKS client handles caching internally.
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.
"""
if self._jwks_client is None:
jwks_url = f"{self._config.server_url.rstrip('/')}/.well-known/jwks"
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)
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)
async def introspect_token(self, token: str) -> dict[str, Any]:
"""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,
}
http = await self._get_http()
response = await http.post(
"/api/login/oauth/introspect",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return response.json()
# =========================================================================
# User Info
# =========================================================================
async 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.
"""
http = await self._get_http()
response = await http.get(
"/api/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
return UserInfo.model_validate(response.json())
# =========================================================================
# User Management (Casdoor Admin API)
# =========================================================================
async 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}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.get("/api/get-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(data.get("data", data))
async def get_users(self) -> list[User]:
"""Get all users in organization.
Returns:
List of User objects.
"""
params = {
"owner": self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.get("/api/get-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 = data.get("data", data) or []
return [User.model_validate(u) for u in users_data]
async def get_user_count(
self,
*,
owner: str | None = None,
is_online: bool | None = None,
) -> int:
"""Get user count in organization.
Args:
owner: Organization name (defaults to config.organization)
is_online: Filter by online status
Returns:
Number of users.
"""
params: dict[str, Any] = {
"owner": owner or self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
if is_online is not None:
params["isOnline"] = str(is_online).lower()
http = await self._get_http()
response = await http.get("/api/get-user-count", params=params)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user count"))
return int(data.get("data", data) if isinstance(data, dict) else data)
async def create_user(self, user: User) -> User:
"""Create new user.
Args:
user: User object to create
Returns:
Created User object.
"""
return await self._modify_user("add-user", user)
async def update_user(self, user: User) -> User:
"""Update existing user.
Args:
user: User object with updated fields
Returns:
Updated User object.
"""
return await self._modify_user("update-user", user)
async def delete_user(self, user: User) -> User:
"""Delete user.
Args:
user: User object to delete
Returns:
Deleted User object.
"""
return await self._modify_user("delete-user", user)
async def _modify_user(self, action: str, user: User) -> User:
"""Modify user via API."""
params = {
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.post(
f"/api/{action}",
params=params,
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 User.model_validate(data.get("data", data))
async def get_application(self) -> Application:
"""Get current application configuration.
Returns:
Application configuration including OAuth2 settings.
"""
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("/api/get-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(data.get("data", data))
# =========================================================================
# Organization
# =========================================================================
async def get_organizations(self) -> list[dict[str, Any]]:
"""Get all organizations.
Returns:
List of organization data.
"""
params = {
"owner": "admin",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.get("/api/get-organizations", params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organizations"))
return data.get("data", data) or []
async def get_organization(self, name: str) -> dict[str, Any]:
"""Get organization by name.
Args:
name: Organization name
Returns:
Organization data.
"""
params = {
"id": f"admin/{name}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.get("/api/get-organization", params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organization"))
return data.get("data", data)
# =========================================================================
# Permissions / Enforcement
# =========================================================================
async def enforce(
self,
permission_id: str,
model_id: str,
resource_id: str,
enforce_id: str,
*,
owner: str | None = None,
request: list[str] | None = None,
) -> bool:
"""Check permission using Casbin.
Args:
permission_id: Permission identifier
model_id: Casbin model identifier
resource_id: Resource identifier
enforce_id: Enforcement identifier
owner: Organization (defaults to config.organization)
request: Casbin request parameters [sub, obj, act, ...]
Returns:
True if permitted, False otherwise.
"""
payload: dict[str, Any] = {
"id": permission_id,
"modelId": model_id,
"resourceId": resource_id,
"enforceId": enforce_id,
"owner": owner or self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
if request:
payload["casbinRequest"] = request
http = await self._get_http()
response = await http.post("/api/enforce", json=payload)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and data.get("status") == "error":
raise ValueError(data.get("msg", "Enforcement failed"))
return bool(data.get("data", data) if isinstance(data, dict) else data)
async def batch_enforce(
self,
permission_id: str,
model_id: str,
enforce_id: str,
*,
owner: str | None = None,
requests: list[list[str]] | None = None,
) -> list[bool]:
"""Batch check permissions using Casbin.
Args:
permission_id: Permission identifier
model_id: Casbin model identifier
enforce_id: Enforcement identifier
owner: Organization (defaults to config.organization)
requests: List of Casbin requests [[sub, obj, act], ...]
Returns:
List of permission results.
"""
payload: dict[str, Any] = {
"id": permission_id,
"modelId": model_id,
"enforceId": enforce_id,
"owner": owner or self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
if requests:
payload["casbinRequest"] = requests
http = await self._get_http()
response = await http.post("/api/batch-enforce", json=payload)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and data.get("status") == "error":
raise ValueError(data.get("msg", "Batch enforcement failed"))
results = data.get("data", data) if isinstance(data, dict) else data
return [bool(r) for r in results]
# =========================================================================
# Roles
# =========================================================================
async def get_roles(self, *, owner: str | None = None) -> list[dict[str, Any]]:
"""Get all roles in organization.
Args:
owner: Organization name (defaults to config.organization)
Returns:
List of role data.
"""
params = {
"owner": owner or self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.get("/api/get-roles", params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get roles"))
return data.get("data", data) or []
async def get_role(
self,
role_name: str,
*,
owner: str | None = None,
) -> dict[str, Any]:
"""Get role by name.
Args:
role_name: Role name
owner: Organization name (defaults to config.organization)
Returns:
Role data.
"""
org = owner or self._config.organization
params = {
"id": f"{org}/{role_name}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.get("/api/get-role", params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get role"))
return data.get("data", data)
async def get_user_roles(
self,
username: str,
*,
owner: str | None = None,
) -> list[dict[str, Any]]:
"""Get roles for user.
Args:
username: Username
owner: Organization name (defaults to config.organization)
Returns:
List of role data.
"""
org = owner or self._config.organization
params = {
"id": f"{org}/{username}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.get("/api/get-user-roles", params=params)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user roles"))
return data.get("data", data) or []
async def add_role_for_user(
self,
username: str,
role_name: str,
*,
owner: str | None = None,
) -> bool:
"""Add role to user.
Args:
username: Username
role_name: Role name to add
owner: Organization name (defaults to config.organization)
Returns:
True if successful.
"""
org = owner or self._config.organization
payload = {
"user": f"{org}/{username}",
"role": f"{org}/{role_name}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.post("/api/add-user-role", json=payload)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to add role for user"))
return True
async def remove_role_from_user(
self,
username: str,
role_name: str,
*,
owner: str | None = None,
) -> bool:
"""Remove role from user.
Args:
username: Username
role_name: Role name to remove
owner: Organization name (defaults to config.organization)
Returns:
True if successful.
"""
org = owner or self._config.organization
payload = {
"user": f"{org}/{username}",
"role": f"{org}/{role_name}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.post("/api/delete-user-role", json=payload)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to remove role from user"))
return True
# =========================================================================
# Lifecycle
# =========================================================================
async def close(self) -> None:
"""Close HTTP client and release resources."""
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: Any) -> None:
await self.close()
# Alias for Casdoor SDK compatibility
AsyncCasdoorSDK = AsyncIAMClient
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
"""Configuration for Hanzo IAM client."""
from __future__ import annotations
import os
from typing import ClassVar
from pydantic import BaseModel, ConfigDict, Field
class IAMConfig(BaseModel):
"""Configuration for Hanzo IAM client.
Can be initialized directly or from environment variables via from_env().
"""
model_config = ConfigDict(frozen=True)
# Environment variable prefix
ENV_PREFIX: ClassVar[str] = "HANZO_IAM_"
server_url: str = Field(description="IAM server URL (e.g., https://hanzo.id)")
client_id: str = Field(description="OAuth2 client ID")
client_secret: str = Field(default="", description="OAuth2 client secret")
organization: str = Field(default="hanzo", description="IAM organization name")
application: str = Field(default="app", description="IAM application name")
certificate: str = Field(default="", description="JWT verification certificate (PEM)")
@classmethod
def from_env(cls, prefix: str | None = None) -> IAMConfig:
"""Create config from environment variables.
Environment variables:
{prefix}ENDPOINT or {prefix}SERVER_URL - IAM server URL
{prefix}CLIENT_ID - OAuth2 client ID
{prefix}CLIENT_SECRET - OAuth2 client secret
{prefix}ORG_NAME or {prefix}ORGANIZATION - Organization name
{prefix}APP_NAME or {prefix}APPLICATION - Application name
{prefix}CERTIFICATE - JWT certificate (PEM content or file path)
"""
p = prefix or cls.ENV_PREFIX
server_url = os.environ.get(f"{p}ENDPOINT") or os.environ.get(f"{p}SERVER_URL", "")
client_id = os.environ.get(f"{p}CLIENT_ID", "")
client_secret = os.environ.get(f"{p}CLIENT_SECRET", "")
organization = (
os.environ.get(f"{p}ORG_NAME")
or os.environ.get(f"{p}ORGANIZATION", "hanzo")
)
application = (
os.environ.get(f"{p}APP_NAME")
or os.environ.get(f"{p}APPLICATION", "app")
)
# Certificate can be content or file path
cert_val = os.environ.get(f"{p}CERTIFICATE", "")
if cert_val and not cert_val.startswith("-----BEGIN"):
# Treat as file path
cert_path = os.path.expanduser(cert_val)
if os.path.isfile(cert_path):
with open(cert_path) as f:
cert_val = f.read()
return cls(
server_url=server_url,
client_id=client_id,
client_secret=client_secret,
organization=organization,
application=application,
certificate=cert_val,
)
@property
def token_endpoint(self) -> str:
"""OAuth2 token endpoint URL."""
return f"{self.server_url}/api/login/oauth/access_token"
@property
def authorize_endpoint(self) -> str:
"""OAuth2 authorization endpoint URL."""
return f"{self.server_url}/login/oauth/authorize"
@property
def userinfo_endpoint(self) -> str:
"""OIDC UserInfo endpoint URL."""
return f"{self.server_url}/api/userinfo"
@property
def api_base(self) -> str:
"""Base URL for API calls."""
return f"{self.server_url}/api"
+376
View File
@@ -0,0 +1,376 @@
"""FastAPI integration for Hanzo IAM.
Provides FastAPI dependencies for authentication and authorization
using Hanzo IAM (hanzo.id, zoo.id, lux.id, pars.id).
Usage:
from hanzo_iam.fastapi import configure, require_auth, get_current_user
# Configure at startup
configure(
client_id="your-client-id",
client_secret="your-client-secret",
org="hanzo",
)
# Use in routes
@app.get("/protected")
async def protected(claims: JWTClaims = Depends(require_auth)):
return {"user": claims.sub}
@app.get("/user")
async def user_info(user: UserInfo = Depends(get_current_user)):
return {"email": user.email}
"""
from __future__ import annotations
import os
from typing import Callable
import httpx
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from hanzo_iam.models import IAMConfig, JWTClaims, Organization, UserInfo
# Global state
_config: IAMConfig | None = None
_jwks_client: PyJWKClient | None = None
# Security scheme
_bearer = HTTPBearer(auto_error=False)
_bearer_required = HTTPBearer(auto_error=True)
def configure(
client_id: str | None = None,
client_secret: str | None = None,
org: str | Organization = Organization.HANZO,
) -> IAMConfig:
"""Configure the global IAM client.
Args:
client_id: OAuth2 client ID (or HANZO_IAM_CLIENT_ID env var)
client_secret: OAuth2 client secret (or HANZO_IAM_CLIENT_SECRET env var)
org: Organization (hanzo, zoo, lux, pars)
Returns:
The configured IAMConfig
Raises:
ValueError: If client_id is not provided
"""
global _config, _jwks_client
# Resolve organization
if isinstance(org, str):
org = Organization(org)
# Get credentials from args or environment
resolved_client_id = client_id or os.getenv("HANZO_IAM_CLIENT_ID", "")
resolved_client_secret = client_secret or os.getenv("HANZO_IAM_CLIENT_SECRET", "")
if not resolved_client_id:
raise ValueError("client_id required (or set HANZO_IAM_CLIENT_ID)")
_config = IAMConfig(
server_url=org.iam_url,
client_id=resolved_client_id,
client_secret=resolved_client_secret,
organization=org.value,
)
# Reset JWKS client to pick up new config
_jwks_client = None
return _config
def get_config() -> IAMConfig:
"""Get the configured IAM config.
Returns:
The current IAMConfig
Raises:
RuntimeError: If configure() has not been called
"""
if _config is None:
raise RuntimeError("IAM not configured. Call configure() first.")
return _config
def _get_jwks_client() -> PyJWKClient:
"""Get or create the JWKS client."""
global _jwks_client
if _jwks_client is None:
config = get_config()
jwks_url = f"{config.server_url}/.well-known/jwks.json"
_jwks_client = PyJWKClient(jwks_url)
return _jwks_client
# =============================================================================
# Token extraction dependencies
# =============================================================================
async def get_token(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> str | None:
"""Extract bearer token from request (optional).
Returns None if no token is present.
"""
if credentials is None:
return None
return credentials.credentials
async def require_token(
credentials: HTTPAuthorizationCredentials = Depends(_bearer_required),
) -> str:
"""Require bearer token from request.
Raises 401 if no token is present.
"""
return credentials.credentials
# =============================================================================
# Token validation dependencies
# =============================================================================
def _validate_token(token: str) -> JWTClaims:
"""Validate JWT token and return claims.
Args:
token: JWT token string
Returns:
JWTClaims with validated claims
Raises:
HTTPException: If token is invalid or expired
"""
config = get_config()
jwks_client = _get_jwks_client()
try:
# Get signing key from JWKS
signing_key = jwks_client.get_signing_key_from_jwt(token)
# Decode and verify token
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256", "ES256"],
audience=config.client_id,
issuer=config.server_url,
)
return JWTClaims.model_validate(payload)
except jwt.ExpiredSignatureError as err:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
) from err
except jwt.InvalidTokenError as err:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {err}",
headers={"WWW-Authenticate": "Bearer"},
) from err
async def get_token_claims(
token: str | None = Depends(get_token),
) -> JWTClaims | None:
"""Validate token and return claims (optional).
Returns None if no token is present.
Raises 401 if token is invalid.
"""
if token is None:
return None
return _validate_token(token)
async def require_auth(
token: str = Depends(require_token),
) -> JWTClaims:
"""Require valid token and return claims.
Raises 401 if no token or invalid token.
"""
return _validate_token(token)
# =============================================================================
# User info dependencies
# =============================================================================
async def _fetch_user_info(token: str) -> UserInfo:
"""Fetch user info from IAM server.
Args:
token: Access token
Returns:
UserInfo from the IAM userinfo endpoint
Raises:
HTTPException: If request fails
"""
config = get_config()
userinfo_url = f"{config.server_url}/api/userinfo"
async with httpx.AsyncClient() as client:
response = await client.get(
userinfo_url,
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Failed to fetch user info",
headers={"WWW-Authenticate": "Bearer"},
)
return UserInfo.model_validate(response.json())
async def get_current_user(
token: str = Depends(require_token),
) -> UserInfo:
"""Get full user info from IAM.
Requires valid token. Fetches user info from IAM userinfo endpoint.
"""
return await _fetch_user_info(token)
async def get_optional_user(
token: str | None = Depends(get_token),
) -> UserInfo | None:
"""Get user info if authenticated, None otherwise.
Does not raise on missing/invalid token.
"""
if token is None:
return None
try:
_validate_token(token) # Validate first
return await _fetch_user_info(token)
except HTTPException:
return None
# =============================================================================
# Authorization dependencies
# =============================================================================
def require_org(allowed_orgs: list[str | Organization]) -> Callable:
"""Create dependency that requires user to be from specific org(s).
Args:
allowed_orgs: List of allowed organization names or Organization enums
Returns:
FastAPI dependency that validates org membership
Usage:
@app.get("/hanzo-only")
async def route(claims: JWTClaims = Depends(require_org(["hanzo"]))):
...
"""
# Normalize to strings
orgs = [o.value if isinstance(o, Organization) else o for o in allowed_orgs]
async def _check_org(claims: JWTClaims = Depends(require_auth)) -> JWTClaims:
user_org = claims.owner or ""
if user_org not in orgs:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access restricted to organizations: {orgs}",
)
return claims
return _check_org
async def require_admin(
claims: JWTClaims = Depends(require_auth),
) -> JWTClaims:
"""Require user to have admin role.
Raises 403 if user is not an admin.
"""
if "admin" not in claims.roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return claims
def require_role(role: str) -> Callable:
"""Create dependency that requires specific role.
Args:
role: Required role name
Returns:
FastAPI dependency that validates role
Usage:
@app.get("/moderators")
async def route(claims: JWTClaims = Depends(require_role("moderator"))):
...
"""
async def _check_role(claims: JWTClaims = Depends(require_auth)) -> JWTClaims:
if role not in claims.roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role required: {role}",
)
return claims
return _check_role
# =============================================================================
# Exports
# =============================================================================
__all__ = [
# Configuration
"configure",
"get_config",
# Token extraction
"get_token",
"require_token",
# Token validation
"get_token_claims",
"require_auth",
# User info
"get_current_user",
"get_optional_user",
# Authorization
"require_org",
"require_admin",
"require_role",
]
+204
View File
@@ -0,0 +1,204 @@
"""Pydantic models for Hanzo IAM (Casdoor-based)."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class Organization(str, Enum):
"""Hanzo IAM organizations with their identity domains."""
HANZO = "hanzo"
ZOO = "zoo"
LUX = "lux"
PARS = "pars"
@property
def iam_url(self) -> str:
"""Return the IAM URL for this organization."""
return f"https://{self.value}.id"
class IAMConfig(BaseModel):
"""Configuration for Hanzo IAM client."""
model_config = ConfigDict(frozen=True)
server_url: str = Field(description="IAM server URL (e.g., https://hanzo.id)")
client_id: str = Field(description="OAuth2 client ID")
client_secret: str = Field(default="", description="OAuth2 client secret")
organization: str = Field(default="hanzo", description="IAM organization name")
application: str = Field(default="app", description="IAM application name")
certificate: str = Field(default="", description="JWT verification certificate (PEM)")
class TokenResponse(BaseModel):
"""OAuth2 token response."""
model_config = ConfigDict(populate_by_name=True)
access_token: str = Field(alias="access_token")
token_type: str = Field(default="Bearer", alias="token_type")
expires_in: int = Field(alias="expires_in")
refresh_token: str | None = Field(default=None, alias="refresh_token")
id_token: str | None = Field(default=None, alias="id_token")
scope: str | None = Field(default=None)
class JWTClaims(BaseModel):
"""JWT claims with standard and Hanzo-specific fields."""
model_config = ConfigDict(populate_by_name=True, extra="allow")
# Standard JWT claims
iss: str | None = Field(default=None, description="Issuer")
sub: str | None = Field(default=None, description="Subject (user ID)")
aud: str | list[str] | None = Field(default=None, description="Audience")
exp: int | None = Field(default=None, description="Expiration time (Unix timestamp)")
iat: int | None = Field(default=None, description="Issued at (Unix timestamp)")
nbf: int | None = Field(default=None, description="Not before (Unix timestamp)")
jti: str | None = Field(default=None, description="JWT ID")
# Hanzo-specific claims
name: str | None = Field(default=None, description="User display name")
email: str | None = Field(default=None, description="User email")
owner: str | None = Field(default=None, description="Organization owner")
roles: list[str] = Field(default_factory=list, description="User roles")
@property
def is_expired(self) -> bool:
"""Check if token is expired."""
if self.exp is None:
return False
return datetime.now().timestamp() > self.exp
class UserInfo(BaseModel):
"""OIDC UserInfo response with Hanzo extensions."""
model_config = ConfigDict(populate_by_name=True, extra="allow")
# Standard OIDC claims
sub: str = Field(description="Subject identifier")
name: str | None = Field(default=None)
given_name: str | None = Field(default=None, alias="given_name")
family_name: str | None = Field(default=None, alias="family_name")
preferred_username: str | None = Field(default=None, alias="preferred_username")
email: str | None = Field(default=None)
email_verified: bool = Field(default=False, alias="email_verified")
picture: str | None = Field(default=None)
locale: str | None = Field(default=None)
updated_at: int | None = Field(default=None, alias="updated_at")
# Hanzo extensions
owner: str | None = Field(default=None, description="Organization owner")
balance: float = Field(default=0.0, description="Account balance")
is_admin: bool = Field(default=False, alias="isAdmin", description="Admin status")
roles: list[str] = Field(default_factory=list, description="User roles")
class User(BaseModel):
"""Full user object from IAM API."""
model_config = ConfigDict(populate_by_name=True, extra="allow")
# Identity
id: str | None = Field(default=None)
owner: str = Field(description="Organization owner")
name: str = Field(description="Username")
display_name: str = Field(default="", alias="displayName")
# Contact
email: str | None = Field(default=None)
phone: str | None = Field(default=None)
country_code: str | None = Field(default=None, alias="countryCode")
# Profile
avatar: str | None = Field(default=None)
avatar_type: str | None = Field(default=None, alias="avatarType")
bio: str | None = Field(default=None)
location: str | None = Field(default=None)
homepage: str | None = Field(default=None)
# Status
is_admin: bool = Field(default=False, alias="isAdmin")
is_deleted: bool = Field(default=False, alias="isDeleted")
is_forbidden: bool = Field(default=False, alias="isForbidden")
is_online: bool = Field(default=False, alias="isOnline")
# Account
type: str | None = Field(default=None)
password: str | None = Field(default=None)
password_salt: str | None = Field(default=None, alias="passwordSalt")
password_type: str | None = Field(default=None, alias="passwordType")
# Verification
email_verified: bool = Field(default=False, alias="emailVerified")
phone_verified: bool = Field(default=False, alias="phoneVerified")
# Hanzo extensions
balance: float = Field(default=0.0)
score: int = Field(default=0)
karma: int = Field(default=0)
ranking: int = Field(default=0)
signup_application: str | None = Field(default=None, alias="signupApplication")
# Permissions
roles: list[str] = Field(default_factory=list)
permissions: list[str] = Field(default_factory=list)
groups: list[str] = Field(default_factory=list)
# Timestamps
created_time: str | None = Field(default=None, alias="createdTime")
updated_time: str | None = Field(default=None, alias="updatedTime")
# Additional properties
properties: dict[str, Any] = Field(default_factory=dict)
class Application(BaseModel):
"""IAM application configuration."""
model_config = ConfigDict(populate_by_name=True, extra="allow")
# Identity
owner: str = Field(description="Organization owner")
name: str = Field(description="Application name")
display_name: str = Field(default="", alias="displayName")
description: str = Field(default="")
logo: str = Field(default="")
homepage_url: str = Field(default="", alias="homepageUrl")
# OAuth2 configuration
client_id: str = Field(alias="clientId")
client_secret: str = Field(default="", alias="clientSecret")
redirect_uris: list[str] = Field(default_factory=list, alias="redirectUris")
grant_types: list[str] = Field(default_factory=list, alias="grantTypes")
response_types: list[str] = Field(default_factory=list, alias="responseTypes")
# Token settings
expire_in_hours: int = Field(default=168, alias="expireInHours")
refresh_expire_in_hours: int = Field(default=0, alias="refreshExpireInHours")
# Providers
providers: list[dict[str, Any]] = Field(default_factory=list)
signup_items: list[dict[str, Any]] = Field(default_factory=list, alias="signupItems")
# Features
enable_password: bool = Field(default=True, alias="enablePassword")
enable_signup: bool = Field(default=True, alias="enableSignUp")
enable_signin_session: bool = Field(default=False, alias="enableSigninSession")
enable_code_signin: bool = Field(default=False, alias="enableCodeSignin")
# Organization
organization: str = Field(default="")
# Certificate for JWT verification
cert: str = Field(default="")
# Timestamps
created_time: str | None = Field(default=None, alias="createdTime")
View File
+63
View File
@@ -0,0 +1,63 @@
[project]
name = "hanzo-iam"
version = "1.0.0"
description = "Hanzo IAM SDK - Identity and Access Management for Python"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.9"
authors = [{ name = "Hanzo AI", email = "[email protected]" }]
keywords = ["oauth2", "oidc", "iam", "hanzo", "authentication", "identity", "access-management"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Security",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
dependencies = [
"httpx>=0.25.0",
"pydantic>=2.0.0",
"pyjwt>=2.8.0",
]
[project.optional-dependencies]
fastapi = ["fastapi>=0.100.0"]
kms = ["hanzo-kms>=1.0.0"]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"ruff>=0.5.0",
"mypy>=1.10.0",
]
[project.urls]
Homepage = "https://hanzo.id"
Documentation = "https://docs.hanzo.ai/iam"
Repository = "https://github.com/hanzoai/python-sdk"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["hanzo_iam"]
[tool.ruff]
target-version = "py39"
line-length = 100
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP"]
ignore = ["E501", "B008"] # B008: Depends() in defaults is standard FastAPI pattern
[tool.mypy]
python_version = "3.9"
strict = true
+155
View File
@@ -0,0 +1,155 @@
"""Tests for FastAPI integration."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hanzo_iam.fastapi import (
configure,
get_config,
get_token,
require_token,
get_token_claims,
require_auth,
require_org,
require_admin,
require_role,
)
from hanzo_iam.models import IAMConfig, JWTClaims, Organization
class TestConfigure:
"""Tests for configure() function."""
def test_configure_with_args(self):
"""Configure with explicit arguments."""
config = configure(
client_id="test-client",
client_secret="test-secret",
org="hanzo",
)
assert config.client_id == "test-client"
assert config.client_secret == "test-secret"
assert config.organization == "hanzo"
assert config.server_url == "https://hanzo.id"
def test_configure_with_organization_enum(self):
"""Configure with Organization enum."""
config = configure(
client_id="test-client",
org=Organization.ZOO,
)
assert config.server_url == "https://zoo.id"
assert config.organization == "zoo"
def test_configure_missing_client_id_raises(self):
"""Missing client_id raises ValueError."""
import os
# Ensure env var is not set
os.environ.pop("HANZO_IAM_CLIENT_ID", None)
with pytest.raises(ValueError, match="client_id required"):
configure(client_id=None)
def test_get_config_before_configure_raises(self):
"""get_config() before configure() raises RuntimeError."""
# Reset global state
import hanzo_iam.fastapi as module
module._config = None
with pytest.raises(RuntimeError, match="IAM not configured"):
get_config()
class TestTokenDependencies:
"""Tests for token extraction dependencies."""
@pytest.fixture
def app(self):
"""Create test FastAPI app."""
configure(client_id="test-client", org="hanzo")
app = FastAPI()
@app.get("/optional")
async def optional_route(token: str | None = None):
# Simulating get_token behavior
return {"token": token}
@app.get("/required")
async def required_route(token: str = ""):
# Simulating require_token behavior
return {"token": token}
return app
def test_optional_token_missing(self, app):
"""Optional token returns None when missing."""
client = TestClient(app)
# Test basic route without actual dependency
response = client.get("/optional")
assert response.status_code == 200
class TestRequireOrg:
"""Tests for require_org dependency factory."""
def test_require_org_normalizes_strings(self):
"""require_org normalizes Organization enums to strings."""
configure(client_id="test-client")
dep = require_org([Organization.HANZO, "zoo"])
# Verify it's a callable (dependency)
assert callable(dep)
def test_require_org_accepts_string_list(self):
"""require_org accepts list of strings."""
configure(client_id="test-client")
dep = require_org(["hanzo", "zoo", "lux"])
assert callable(dep)
class TestRequireRole:
"""Tests for require_role dependency factory."""
def test_require_role_returns_callable(self):
"""require_role returns a callable dependency."""
configure(client_id="test-client")
dep = require_role("moderator")
assert callable(dep)
class TestRequireAdmin:
"""Tests for require_admin dependency."""
def test_require_admin_is_async(self):
"""require_admin is an async function."""
import asyncio
assert asyncio.iscoroutinefunction(require_admin)
class TestModuleExports:
"""Tests for module exports."""
def test_all_exports_exist(self):
"""All __all__ exports exist."""
from hanzo_iam import fastapi
expected = [
"configure",
"get_config",
"get_token",
"require_token",
"get_token_claims",
"require_auth",
"get_current_user",
"get_optional_user",
"require_org",
"require_admin",
"require_role",
]
for name in expected:
assert hasattr(fastapi, name), f"Missing export: {name}"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -80,8 +80,14 @@ ENV_VAR_MAPPINGS: dict[str, list[str]] = {
"imgix": ["IMGIX_API_KEY"],
"bunny": ["BUNNY_API_KEY", "BUNNY_ACCESS_KEY"],
"backblaze": ["B2_APPLICATION_KEY_ID", "B2_APPLICATION_KEY"],
# Hanzo
# Hanzo Services
"hanzo": ["HANZO_API_KEY", "HANZO_TOKEN"],
"hanzo-iam": ["HANZO_API_KEY", "HANZO_TOKEN"],
"hanzo-gateway": ["HANZO_API_KEY", "HANZO_TOKEN"],
"hanzo-commerce": ["HANZO_API_KEY", "HANZO_TOKEN"],
"hanzo-vector": ["HANZO_API_KEY", "HANZO_TOKEN"],
"hanzo-cloud": ["HANZO_API_KEY", "HANZO_TOKEN"],
"hanzo-nexus": ["HANZO_API_KEY", "HANZO_TOKEN"],
}
@@ -290,12 +296,61 @@ PROVIDER_CONFIGS: dict[str, ProviderConfig] = {
auth_prefix="",
env_vars=["ALGOLIA_API_KEY"],
),
# Hanzo
# Hanzo Services
"hanzo": ProviderConfig(
name="hanzo",
display_name="Hanzo AI",
base_url="https://api.hanzo.ai/v1",
auth_type=AuthType.BEARER,
spec_url="file:///Users/z/work/hanzo/openapi/hanzo.yaml",
env_vars=["HANZO_API_KEY", "HANZO_TOKEN"],
),
"hanzo-iam": ProviderConfig(
name="hanzo-iam",
display_name="Hanzo IAM",
base_url="https://iam.hanzo.ai",
auth_type=AuthType.BEARER,
spec_url="file:///Users/z/work/hanzo/openapi/iam/openapi.yaml",
env_vars=["HANZO_API_KEY", "HANZO_TOKEN"],
),
"hanzo-gateway": ProviderConfig(
name="hanzo-gateway",
display_name="Hanzo Gateway",
base_url="https://gateway.hanzo.ai",
auth_type=AuthType.BEARER,
spec_url="file:///Users/z/work/hanzo/openapi/gateway/openapi.yaml",
env_vars=["HANZO_API_KEY", "HANZO_TOKEN"],
),
"hanzo-commerce": ProviderConfig(
name="hanzo-commerce",
display_name="Hanzo Commerce",
base_url="https://api.hanzo.ai/v1",
auth_type=AuthType.BEARER,
spec_url="file:///Users/z/work/hanzo/openapi/commerce/openapi.yaml",
env_vars=["HANZO_API_KEY", "HANZO_TOKEN"],
),
"hanzo-vector": ProviderConfig(
name="hanzo-vector",
display_name="Hanzo Vector",
base_url="https://vector.hanzo.ai",
auth_type=AuthType.BEARER,
spec_url="file:///Users/z/work/hanzo/openapi/vector/openapi.yaml",
env_vars=["HANZO_API_KEY", "HANZO_TOKEN"],
),
"hanzo-cloud": ProviderConfig(
name="hanzo-cloud",
display_name="Hanzo Cloud",
base_url="https://cloud.hanzo.ai",
auth_type=AuthType.BEARER,
spec_url="file:///Users/z/work/hanzo/openapi/cloud/openapi.yaml",
env_vars=["HANZO_API_KEY", "HANZO_TOKEN"],
),
"hanzo-nexus": ProviderConfig(
name="hanzo-nexus",
display_name="Hanzo Nexus",
base_url="https://nexus.hanzo.ai",
auth_type=AuthType.BEARER,
spec_url="file:///Users/z/work/hanzo/openapi/nexus/openapi.yaml",
env_vars=["HANZO_API_KEY", "HANZO_TOKEN"],
),
# Popular APIs from handmade-openapis
+1524
View File
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
# Hanzo Web3 SDK
Enterprise blockchain infrastructure SDK for Python. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.
## Installation
```bash
pip install hanzo-web3
# Or with uv
uv add hanzo-web3
```
## Quick Start
```python
from hanzo_web3 import Client
# Initialize client
client = Client(api_key="hz_live_...")
# Get latest block number
block = client.rpc.eth_block_number(chain="ethereum")
print(f"Latest block: {block}")
# Get token balances
balances = client.tokens.get_balances("0x...", chain="polygon")
for token in balances:
print(f"{token['symbol']}: {token['balance_formatted']}")
# Get NFTs owned
nfts = client.nfts.get_owned("0x...", chain="ethereum")
for nft in nfts:
print(f"{nft['name']} #{nft['token_id']}")
```
## Async Client
```python
import asyncio
from hanzo_web3 import AsyncClient
async def main():
async with AsyncClient(api_key="hz_live_...") as client:
# Parallel requests
eth_block, polygon_block = await asyncio.gather(
client.rpc.eth_block_number(chain="ethereum"),
client.rpc.eth_block_number(chain="polygon"),
)
print(f"ETH: {eth_block}, Polygon: {polygon_block}")
asyncio.run(main())
```
## Features
### RPC API
Direct JSON-RPC access to 100+ chains:
```python
# Any RPC method
result = client.rpc.call("eth_getBalance", ["0x...", "latest"], chain="ethereum")
# Convenience methods
balance = client.rpc.eth_get_balance("0x...", chain="ethereum")
```
### Token API
ERC-20 token data:
```python
# Get all token balances
balances = client.tokens.get_balances("0x...", chain="ethereum")
# Get token metadata
metadata = client.tokens.get_metadata("0xA0b8...", chain="ethereum")
```
### NFT API
NFT collections and metadata:
```python
# Get owned NFTs
nfts = client.nfts.get_owned("0x...", chain="ethereum")
# Get NFT metadata
metadata = client.nfts.get_metadata("0x...", "1234", chain="ethereum")
```
### Smart Wallets (ERC-4337)
Account abstraction:
```python
# Create smart wallet
wallet = client.wallets.create(owner="0x...", chain="base")
print(f"Smart wallet: {wallet['address']}")
# Get wallet details
info = client.wallets.get(wallet['address'], chain="base")
```
### Webhooks
Real-time event notifications:
```python
# Create webhook
webhook = client.webhooks.create(
url="https://your-server.com/webhook",
event_type="ADDRESS_ACTIVITY",
chain="ethereum",
filters={"addresses": ["0x..."]}
)
# List webhooks
webhooks = client.webhooks.list()
# Delete webhook
client.webhooks.delete(webhook["id"])
```
## Supported Chains
| Chain | Networks |
|-------|----------|
| Ethereum | mainnet, sepolia, holesky |
| Polygon | mainnet, amoy |
| Arbitrum | mainnet, sepolia |
| Optimism | mainnet, sepolia |
| Base | mainnet, sepolia |
| Avalanche | mainnet, fuji |
| BNB Chain | mainnet, testnet |
| Lux | mainnet, testnet |
| Solana | mainnet, devnet |
| + 90 more | ... |
## Environment Variables
```bash
# API Key (alternative to passing in code)
export HANZO_WEB3_API_KEY=hz_live_...
# Custom API URL (for white-label deployments)
export HANZO_WEB3_BASE_URL=https://api.lux.cloud
```
## Error Handling
```python
from hanzo_web3 import Client
from hanzo_web3.exceptions import (
AuthenticationError,
RateLimitError,
ChainNotSupportedError,
)
try:
client = Client(api_key="invalid")
client.rpc.eth_block_number()
except AuthenticationError:
print("Invalid API key")
except RateLimitError as e:
print(f"Rate limited. Retry after {e.retry_after}s")
except ChainNotSupportedError as e:
print(f"Chain {e.chain} not supported")
```
## Links
- [Documentation](https://docs.web3.hanzo.ai)
- [Dashboard](https://web3.hanzo.ai)
- [API Reference](https://docs.web3.hanzo.ai/api)
- [GitHub](https://github.com/hanzoai/python-sdk)
## License
Apache 2.0 - See [LICENSE](LICENSE) for details.
+53
View File
@@ -0,0 +1,53 @@
[project]
name = "hanzo-web3"
version = "0.1.0"
description = "Hanzo Web3 - Enterprise Blockchain Infrastructure SDK"
authors = [
{name = "Hanzo AI", email = "[email protected]"},
]
dependencies = [
"httpx>=0.23.0",
"pydantic>=2.0.0",
"web3>=6.0.0",
]
readme = "README.md"
requires-python = ">= 3.10"
keywords = ["blockchain", "web3", "ethereum", "rpc", "nft", "tokens", "hanzo", "erc4337"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet :: WWW/HTTP",
]
[project.optional-dependencies]
async = ["aiohttp>=3.9.0"]
all = [
"aiohttp>=3.9.0",
"websockets>=12.0",
]
[project.urls]
Homepage = "https://web3.hanzo.ai"
Repository = "https://github.com/hanzoai/python-sdk"
Documentation = "https://docs.web3.hanzo.ai"
"Bug Tracker" = "https://github.com/hanzoai/python-sdk/issues"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build]
include = [
"src/hanzo_web3",
]
[tool.hatch.build.targets.wheel]
packages = ["src/hanzo_web3"]
+46
View File
@@ -0,0 +1,46 @@
"""Hanzo Web3 SDK - Enterprise Blockchain Infrastructure.
Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.
Example:
>>> from hanzo_web3 import Client
>>> client = Client(api_key="hz_live_...")
>>> balance = await client.tokens.get_balance("0x...", chain="ethereum")
"""
from hanzo_web3.client import Client, AsyncClient
from hanzo_web3.types import (
Chain,
Network,
TokenBalance,
NFTMetadata,
Transaction,
Webhook,
SmartWallet,
)
from hanzo_web3.exceptions import (
HanzoWeb3Error,
AuthenticationError,
RateLimitError,
ChainNotSupportedError,
)
__version__ = "0.1.0"
__all__ = [
# Clients
"Client",
"AsyncClient",
# Types
"Chain",
"Network",
"TokenBalance",
"NFTMetadata",
"Transaction",
"Webhook",
"SmartWallet",
# Exceptions
"HanzoWeb3Error",
"AuthenticationError",
"RateLimitError",
"ChainNotSupportedError",
]
+487
View File
@@ -0,0 +1,487 @@
"""Hanzo Web3 Client - Sync and Async clients for blockchain APIs."""
from __future__ import annotations
import os
from typing import Any, Optional
import httpx
from pydantic import BaseModel
from hanzo_web3.types import Chain, Network
from hanzo_web3.exceptions import AuthenticationError, HanzoWeb3Error
class ClientConfig(BaseModel):
"""Client configuration."""
api_key: str
base_url: str = "https://api.web3.hanzo.ai"
timeout: float = 30.0
max_retries: int = 3
class RPCClient:
"""JSON-RPC client for blockchain interactions."""
def __init__(self, http_client: httpx.AsyncClient, config: ClientConfig):
self._http = http_client
self._config = config
async def call(
self,
method: str,
params: list[Any] | None = None,
chain: str = "ethereum",
network: str = "mainnet",
) -> Any:
"""Execute JSON-RPC call.
Args:
method: RPC method name (e.g., "eth_blockNumber")
params: RPC parameters
chain: Chain name (ethereum, polygon, arbitrum, etc.)
network: Network name (mainnet, sepolia, etc.)
Returns:
RPC result
"""
response = await self._http.post(
f"{self._config.base_url}/v1/rpc/{chain}/{network}",
json={
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params or [],
},
headers={"X-API-Key": self._config.api_key},
)
if response.status_code == 401:
raise AuthenticationError("Invalid API key")
data = response.json()
if "error" in data:
raise HanzoWeb3Error(f"RPC error: {data['error']}")
return data.get("result")
async def eth_block_number(self, chain: str = "ethereum", network: str = "mainnet") -> int:
"""Get latest block number."""
result = await self.call("eth_blockNumber", chain=chain, network=network)
return int(result, 16)
async def eth_get_balance(
self, address: str, chain: str = "ethereum", network: str = "mainnet"
) -> int:
"""Get ETH balance for address."""
result = await self.call(
"eth_getBalance", [address, "latest"], chain=chain, network=network
)
return int(result, 16)
class TokensClient:
"""Token API client."""
def __init__(self, http_client: httpx.AsyncClient, config: ClientConfig):
self._http = http_client
self._config = config
async def get_balances(
self,
address: str,
chain: str = "ethereum",
network: str = "mainnet",
) -> list[dict[str, Any]]:
"""Get all token balances for address.
Args:
address: Wallet address
chain: Chain name
network: Network name
Returns:
List of token balances
"""
response = await self._http.get(
f"{self._config.base_url}/v1/tokens/{chain}/{network}/balances/{address}",
headers={"X-API-Key": self._config.api_key},
)
return response.json().get("balances", [])
async def get_metadata(
self,
contract_address: str,
chain: str = "ethereum",
network: str = "mainnet",
) -> dict[str, Any]:
"""Get token metadata.
Args:
contract_address: Token contract address
chain: Chain name
network: Network name
Returns:
Token metadata (name, symbol, decimals, etc.)
"""
response = await self._http.get(
f"{self._config.base_url}/v1/tokens/{chain}/{network}/metadata/{contract_address}",
headers={"X-API-Key": self._config.api_key},
)
return response.json()
class NFTsClient:
"""NFT API client."""
def __init__(self, http_client: httpx.AsyncClient, config: ClientConfig):
self._http = http_client
self._config = config
async def get_owned(
self,
address: str,
chain: str = "ethereum",
network: str = "mainnet",
) -> list[dict[str, Any]]:
"""Get all NFTs owned by address.
Args:
address: Wallet address
chain: Chain name
network: Network name
Returns:
List of owned NFTs
"""
response = await self._http.get(
f"{self._config.base_url}/v1/nfts/{chain}/{network}/owned/{address}",
headers={"X-API-Key": self._config.api_key},
)
return response.json().get("nfts", [])
async def get_metadata(
self,
contract_address: str,
token_id: str,
chain: str = "ethereum",
network: str = "mainnet",
) -> dict[str, Any]:
"""Get NFT metadata.
Args:
contract_address: NFT contract address
token_id: Token ID
chain: Chain name
network: Network name
Returns:
NFT metadata
"""
response = await self._http.get(
f"{self._config.base_url}/v1/nfts/{chain}/{network}/metadata/{contract_address}/{token_id}",
headers={"X-API-Key": self._config.api_key},
)
return response.json()
class WalletsClient:
"""Smart Wallet (ERC-4337) client."""
def __init__(self, http_client: httpx.AsyncClient, config: ClientConfig):
self._http = http_client
self._config = config
async def create(
self,
owner: str,
chain: str = "ethereum",
network: str = "mainnet",
) -> dict[str, Any]:
"""Create a new smart wallet.
Args:
owner: Owner EOA address
chain: Chain name
network: Network name
Returns:
Smart wallet details (address, etc.)
"""
response = await self._http.post(
f"{self._config.base_url}/v1/wallets/{chain}/{network}/create",
json={"owner": owner},
headers={"X-API-Key": self._config.api_key},
)
return response.json()
async def get(
self,
address: str,
chain: str = "ethereum",
network: str = "mainnet",
) -> dict[str, Any]:
"""Get smart wallet details.
Args:
address: Smart wallet address
chain: Chain name
network: Network name
Returns:
Wallet details
"""
response = await self._http.get(
f"{self._config.base_url}/v1/wallets/{chain}/{network}/{address}",
headers={"X-API-Key": self._config.api_key},
)
return response.json()
class WebhooksClient:
"""Webhooks client."""
def __init__(self, http_client: httpx.AsyncClient, config: ClientConfig):
self._http = http_client
self._config = config
async def create(
self,
url: str,
event_type: str,
chain: str = "ethereum",
network: str = "mainnet",
filters: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Create a webhook.
Args:
url: Webhook URL
event_type: Event type (ADDRESS_ACTIVITY, NFT_ACTIVITY, etc.)
chain: Chain name
network: Network name
filters: Event filters
Returns:
Webhook details
"""
response = await self._http.post(
f"{self._config.base_url}/v1/webhooks",
json={
"url": url,
"event_type": event_type,
"chain": chain,
"network": network,
"filters": filters or {},
},
headers={"X-API-Key": self._config.api_key},
)
return response.json()
async def list(self) -> list[dict[str, Any]]:
"""List all webhooks.
Returns:
List of webhooks
"""
response = await self._http.get(
f"{self._config.base_url}/v1/webhooks",
headers={"X-API-Key": self._config.api_key},
)
return response.json().get("webhooks", [])
async def delete(self, webhook_id: str) -> bool:
"""Delete a webhook.
Args:
webhook_id: Webhook ID
Returns:
True if deleted
"""
response = await self._http.delete(
f"{self._config.base_url}/v1/webhooks/{webhook_id}",
headers={"X-API-Key": self._config.api_key},
)
return response.status_code == 204
class AsyncClient:
"""Async Hanzo Web3 client.
Example:
>>> client = AsyncClient(api_key="hz_live_...")
>>> balance = await client.tokens.get_balances("0x...")
"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: str = "https://api.web3.hanzo.ai",
timeout: float = 30.0,
):
"""Initialize async client.
Args:
api_key: API key (or set HANZO_WEB3_API_KEY env var)
base_url: API base URL
timeout: Request timeout in seconds
"""
api_key = api_key or os.environ.get("HANZO_WEB3_API_KEY")
if not api_key:
raise AuthenticationError(
"API key required. Pass api_key or set HANZO_WEB3_API_KEY env var."
)
self._config = ClientConfig(
api_key=api_key,
base_url=base_url,
timeout=timeout,
)
self._http = httpx.AsyncClient(timeout=timeout)
# Initialize sub-clients
self.rpc = RPCClient(self._http, self._config)
self.tokens = TokensClient(self._http, self._config)
self.nfts = NFTsClient(self._http, self._config)
self.wallets = WalletsClient(self._http, self._config)
self.webhooks = WebhooksClient(self._http, self._config)
async def __aenter__(self) -> "AsyncClient":
return self
async def __aexit__(self, *args: Any) -> None:
await self._http.aclose()
async def close(self) -> None:
"""Close the client."""
await self._http.aclose()
class Client:
"""Sync Hanzo Web3 client (wrapper around AsyncClient).
Example:
>>> client = Client(api_key="hz_live_...")
>>> balance = client.rpc.eth_block_number()
"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: str = "https://api.web3.hanzo.ai",
timeout: float = 30.0,
):
"""Initialize sync client.
Args:
api_key: API key (or set HANZO_WEB3_API_KEY env var)
base_url: API base URL
timeout: Request timeout in seconds
"""
import asyncio
self._async_client = AsyncClient(
api_key=api_key,
base_url=base_url,
timeout=timeout,
)
self._loop = asyncio.new_event_loop()
def _run(self, coro: Any) -> Any:
"""Run async coroutine synchronously."""
return self._loop.run_until_complete(coro)
@property
def rpc(self) -> "SyncRPCClient":
return SyncRPCClient(self._async_client.rpc, self._run)
@property
def tokens(self) -> "SyncTokensClient":
return SyncTokensClient(self._async_client.tokens, self._run)
@property
def nfts(self) -> "SyncNFTsClient":
return SyncNFTsClient(self._async_client.nfts, self._run)
@property
def wallets(self) -> "SyncWalletsClient":
return SyncWalletsClient(self._async_client.wallets, self._run)
@property
def webhooks(self) -> "SyncWebhooksClient":
return SyncWebhooksClient(self._async_client.webhooks, self._run)
def close(self) -> None:
"""Close the client."""
self._run(self._async_client.close())
self._loop.close()
# Sync wrapper classes
class SyncRPCClient:
def __init__(self, async_client: RPCClient, runner: Any):
self._async = async_client
self._run = runner
def call(self, method: str, params: list | None = None, **kwargs: Any) -> Any:
return self._run(self._async.call(method, params, **kwargs))
def eth_block_number(self, **kwargs: Any) -> int:
return self._run(self._async.eth_block_number(**kwargs))
def eth_get_balance(self, address: str, **kwargs: Any) -> int:
return self._run(self._async.eth_get_balance(address, **kwargs))
class SyncTokensClient:
def __init__(self, async_client: TokensClient, runner: Any):
self._async = async_client
self._run = runner
def get_balances(self, address: str, **kwargs: Any) -> list:
return self._run(self._async.get_balances(address, **kwargs))
def get_metadata(self, contract_address: str, **kwargs: Any) -> dict:
return self._run(self._async.get_metadata(contract_address, **kwargs))
class SyncNFTsClient:
def __init__(self, async_client: NFTsClient, runner: Any):
self._async = async_client
self._run = runner
def get_owned(self, address: str, **kwargs: Any) -> list:
return self._run(self._async.get_owned(address, **kwargs))
def get_metadata(self, contract_address: str, token_id: str, **kwargs: Any) -> dict:
return self._run(self._async.get_metadata(contract_address, token_id, **kwargs))
class SyncWalletsClient:
def __init__(self, async_client: WalletsClient, runner: Any):
self._async = async_client
self._run = runner
def create(self, owner: str, **kwargs: Any) -> dict:
return self._run(self._async.create(owner, **kwargs))
def get(self, address: str, **kwargs: Any) -> dict:
return self._run(self._async.get(address, **kwargs))
class SyncWebhooksClient:
def __init__(self, async_client: WebhooksClient, runner: Any):
self._async = async_client
self._run = runner
def create(self, url: str, event_type: str, **kwargs: Any) -> dict:
return self._run(self._async.create(url, event_type, **kwargs))
def list(self) -> list:
return self._run(self._async.list())
def delete(self, webhook_id: str) -> bool:
return self._run(self._async.delete(webhook_id))
@@ -0,0 +1,81 @@
"""Hanzo Web3 SDK exceptions."""
class HanzoWeb3Error(Exception):
"""Base exception for Hanzo Web3 SDK."""
pass
class AuthenticationError(HanzoWeb3Error):
"""Invalid or missing API key."""
pass
class RateLimitError(HanzoWeb3Error):
"""Rate limit exceeded."""
def __init__(
self,
message: str = "Rate limit exceeded",
retry_after: int | None = None,
):
super().__init__(message)
self.retry_after = retry_after
class ChainNotSupportedError(HanzoWeb3Error):
"""Chain or network not supported."""
def __init__(self, chain: str, network: str | None = None):
msg = f"Chain '{chain}' not supported"
if network:
msg = f"Network '{network}' on chain '{chain}' not supported"
super().__init__(msg)
self.chain = chain
self.network = network
class QuotaExceededError(HanzoWeb3Error):
"""Compute unit quota exceeded."""
def __init__(
self,
message: str = "Compute unit quota exceeded",
current_usage: int | None = None,
limit: int | None = None,
):
super().__init__(message)
self.current_usage = current_usage
self.limit = limit
class RPCError(HanzoWeb3Error):
"""JSON-RPC error from the node."""
def __init__(self, code: int, message: str, data: str | None = None):
super().__init__(f"RPC Error {code}: {message}")
self.code = code
self.message = message
self.data = data
class WebhookError(HanzoWeb3Error):
"""Webhook configuration or delivery error."""
pass
class WalletError(HanzoWeb3Error):
"""Smart wallet operation error."""
pass
class TransactionError(HanzoWeb3Error):
"""Transaction submission or execution error."""
def __init__(self, message: str, tx_hash: str | None = None):
super().__init__(message)
self.tx_hash = tx_hash
+144
View File
@@ -0,0 +1,144 @@
"""Hanzo Web3 SDK types."""
from __future__ import annotations
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel
class Chain(str, Enum):
"""Supported blockchains."""
ETHEREUM = "ethereum"
POLYGON = "polygon"
ARBITRUM = "arbitrum"
OPTIMISM = "optimism"
BASE = "base"
AVALANCHE = "avalanche"
BNB = "bnb"
LUX = "lux"
SOLANA = "solana"
BITCOIN = "bitcoin"
class Network(str, Enum):
"""Network types."""
MAINNET = "mainnet"
TESTNET = "testnet"
SEPOLIA = "sepolia"
GOERLI = "goerli"
HOLESKY = "holesky"
DEVNET = "devnet"
class TokenBalance(BaseModel):
"""Token balance."""
contract_address: str
symbol: str
name: str
decimals: int
balance: str
balance_formatted: float
logo_uri: Optional[str] = None
price_usd: Optional[float] = None
value_usd: Optional[float] = None
class NFTMetadata(BaseModel):
"""NFT metadata."""
contract_address: str
token_id: str
name: Optional[str] = None
description: Optional[str] = None
image_uri: Optional[str] = None
animation_uri: Optional[str] = None
external_uri: Optional[str] = None
attributes: list[dict[str, Any]] = []
token_standard: str = "ERC721"
class Transaction(BaseModel):
"""Transaction details."""
hash: str
block_number: int
block_hash: str
from_address: str
to_address: Optional[str] = None
value: str
gas: int
gas_price: str
gas_used: Optional[int] = None
nonce: int
status: int = 1 # 1 = success, 0 = failed
timestamp: Optional[int] = None
class Webhook(BaseModel):
"""Webhook configuration."""
id: str
url: str
event_type: str
chain: str
network: str
filters: dict[str, Any] = {}
active: bool = True
created_at: str
class WebhookEventType(str, Enum):
"""Webhook event types."""
ADDRESS_ACTIVITY = "ADDRESS_ACTIVITY"
MINED_TRANSACTION = "MINED_TRANSACTION"
NFT_ACTIVITY = "NFT_ACTIVITY"
TOKEN_TRANSFER = "TOKEN_TRANSFER"
INTERNAL_TRANSFER = "INTERNAL_TRANSFER"
NEW_BLOCK = "NEW_BLOCK"
class SmartWallet(BaseModel):
"""Smart wallet (ERC-4337)."""
address: str
owner: str
chain: str
network: str
factory: str
is_deployed: bool = False
nonce: int = 0
created_at: str
class UserOperation(BaseModel):
"""ERC-4337 User Operation."""
sender: str
nonce: str
init_code: str = "0x"
call_data: str
call_gas_limit: str
verification_gas_limit: str
pre_verification_gas: str
max_fee_per_gas: str
max_priority_fee_per_gas: str
paymaster_and_data: str = "0x"
signature: str = "0x"
class GasEstimate(BaseModel):
"""Gas price estimate."""
chain: str
network: str
base_fee: str
max_fee: str
max_priority_fee: str
gas_price: str # Legacy gas price
timestamp: int
+35
View File
@@ -223,6 +223,41 @@ def trace(trace_id: str, service: str, min_duration: str):
console.print("[dim]No trace found[/dim]")
# Top-level login/logout shortcuts
@cli.command()
@click.option("--email", "-e", help="Email address")
@click.option("--password", "-p", help="Password (not recommended, use prompt)")
@click.option("--api-key", "-k", help="API key for direct authentication")
@click.option("--web", "-w", is_flag=True, help="Login via browser (device code flow)")
@click.option("--headless", is_flag=True, help="Headless mode - don't open browser")
@click.pass_context
def login(ctx, email, password, api_key, web, headless):
"""Login to Hanzo AI (shortcut for 'hanzo auth login').
\b
Examples:
hanzo login # Interactive device code flow
hanzo login --web # Open browser for authentication
hanzo login -k sk-xxx # Direct API key authentication
hanzo login -e [email protected] # Email/password login
"""
ctx.invoke(auth.login, email=email, password=password, api_key=api_key, web=web, headless=headless)
@cli.command()
@click.pass_context
def logout(ctx):
"""Logout from Hanzo AI (shortcut for 'hanzo auth logout')."""
ctx.invoke(auth.logout)
@cli.command()
@click.pass_context
def whoami(ctx):
"""Show current user (shortcut for 'hanzo auth whoami')."""
ctx.invoke(auth.whoami)
# Alias observe -> o11y
cli.add_command(o11y.o11y_group, name="observe")
+314 -101
View File
@@ -2,18 +2,29 @@
import os
import json
import secrets
import threading
import webbrowser
from http.server import HTTPServer, BaseHTTPRequestHandler
from typing import Optional
from pathlib import Path
from datetime import datetime
from urllib.parse import urlencode, parse_qs, urlparse
import click
from rich import box
from rich.panel import Panel
from rich.table import Table
from rich.prompt import Prompt
from ..utils.output import console
# OAuth constants
HANZO_IAM_URL = "https://hanzo.id"
HANZO_CLIENT_ID = "app-hanzo"
CALLBACK_PORT = 1456
CALLBACK_PATH = "/callback"
CALLBACK_URI = f"http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}"
class AuthManager:
"""Manage Hanzo authentication."""
@@ -73,120 +84,324 @@ def auth_group():
@auth_group.command()
@click.option("--email", "-e", help="Email address")
@click.option("--password", "-p", help="Password (not recommended, use prompt)")
@click.option("--api-key", "-k", help="API key for direct authentication")
@click.option("--web", "-w", is_flag=True, help="Login via browser (device code flow)")
@click.option("--headless", is_flag=True, help="Headless mode - don't open browser")
@click.option("--device-code", is_flag=True, help="Device code flow (for SSH/headless)")
@click.option("--headless", is_flag=True, help="Don't open browser automatically")
@click.pass_context
def login(ctx, email: str, password: str, api_key: str, web: bool, headless: bool):
def login(ctx, api_key: str, device_code: bool, headless: bool):
"""Login to Hanzo AI.
Opens your browser to hanzo.id where you can sign in with
email/password, GitHub, Google, or any configured provider.
\b
Examples:
hanzo auth login # Interactive device code flow
hanzo auth login --web # Open browser for authentication
hanzo auth login --headless # Device code without opening browser
hanzo auth login -k sk-xxx # Direct API key authentication
hanzo auth login -e [email protected] # Email/password login
hanzo auth login # Browser login (default)
hanzo auth login --device-code # Device code for SSH/headless
hanzo auth login -k sk-xxx # Direct API key
"""
import asyncio
auth_mgr = AuthManager()
# Check if already authenticated
if auth_mgr.is_authenticated():
console.print("[yellow]Already authenticated[/yellow]")
auth = auth_mgr.load_auth()
if auth.get("email"):
console.print(f"Logged in as: {auth['email']}")
return
try:
if api_key:
# Direct API key authentication
console.print("Authenticating with API key...")
auth = {
auth = auth_mgr.load_auth()
auth.update({
"api_key": api_key,
"logged_in": True,
"last_login": datetime.now().isoformat(),
}
})
auth_mgr.save_auth(auth)
console.print("[green]✓[/green] Successfully authenticated with API key")
console.print(f"You are now logged in with API key {api_key[:8]}***.")
elif web or (not email and not password and not api_key):
# Device code flow - works on remote/headless systems
console.print("[cyan]Starting device code authentication...[/cyan]")
try:
from hanzoai.auth import HanzoAuth
async def do_device_auth():
hanzo_auth = HanzoAuth()
return await hanzo_auth.login_with_device_code(
open_browser=not headless
)
result = asyncio.run(do_device_auth())
auth = {
"token": result.get("token"),
"email": result.get("email"),
"logged_in": True,
"last_login": datetime.now().isoformat(),
}
auth_mgr.save_auth(auth)
email = result.get("email", "user")
console.print(f"[green]✓[/green] Logged in as {email}")
except ImportError:
console.print("[yellow]Device auth requires hanzoai package[/yellow]")
console.print("Install with: pip install hanzoai")
console.print()
console.print("[dim]Or use API key: hanzo auth login -k YOUR_KEY[/dim]")
elif device_code:
_login_device_code(auth_mgr, headless)
else:
# Email/password authentication
if not email:
email = Prompt.ask("Email")
if not password:
password = Prompt.ask("Password", password=True)
console.print("Authenticating...")
try:
from hanzoai.auth import HanzoAuth
async def do_email_auth():
hanzo_auth = HanzoAuth()
return await hanzo_auth.login(email, password)
result = asyncio.run(do_email_auth())
auth = {
"email": email,
"token": result.get("token"),
"logged_in": True,
"last_login": datetime.now().isoformat(),
}
auth_mgr.save_auth(auth)
console.print(f"[green]✓[/green] Logged in as {email}")
except ImportError:
# Fallback to saving credentials locally
auth = {
"email": email,
"logged_in": True,
"last_login": datetime.now().isoformat(),
}
auth_mgr.save_auth(auth)
console.print(f"[green]✓[/green] Credentials saved for {email}")
_login_browser_oauth(auth_mgr)
except Exception as e:
console.print(f"[red]Login failed: {e}[/red]")
def _get_iam_url(auth_mgr: AuthManager) -> str:
"""Get the IAM URL from env or stored config."""
existing = auth_mgr.load_auth()
return os.getenv("IAM_URL", os.getenv("HANZO_IAM_URL",
existing.get("iam_url", HANZO_IAM_URL)))
def _decode_jwt_claims(token: str) -> dict:
"""Decode JWT payload without verification (for extracting email/name)."""
import base64
try:
parts = token.split(".")
if len(parts) < 2:
return {}
payload = parts[1]
# Add padding
payload += "=" * (4 - len(payload) % 4)
decoded = base64.urlsafe_b64decode(payload)
return json.loads(decoded)
except Exception:
return {}
def _login_browser_oauth(auth_mgr: AuthManager):
"""Browser-based OAuth login using only stdlib (like gcloud auth login)."""
import urllib.request
iam_url = _get_iam_url(auth_mgr)
state = secrets.token_urlsafe(32)
# Result container for the callback handler
auth_result = {"code": None, "error": None}
server_ready = threading.Event()
class CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path != CALLBACK_PATH:
self.send_response(404)
self.end_headers()
return
params = parse_qs(parsed.query)
# Verify state
if params.get("state", [None])[0] != state:
self.send_response(400)
self.end_headers()
self.wfile.write(b"Invalid state parameter.")
auth_result["error"] = "Invalid state"
return
if "error" in params:
self.send_response(400)
self.end_headers()
msg = params.get("error_description", params["error"])[0]
self.wfile.write(msg.encode())
auth_result["error"] = msg
return
auth_result["code"] = params.get("code", [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h2>Authentication successful!</h2>"
b"<p>You can close this window and return to the terminal.</p>"
b"</body></html>"
)
def log_message(self, format, *args):
pass # Suppress server logs
# Start local callback server
server = HTTPServer(("localhost", CALLBACK_PORT), CallbackHandler)
server.timeout = 120 # 2 minute timeout
def serve():
server_ready.set()
server.handle_request() # Handle exactly one request
server_thread = threading.Thread(target=serve, daemon=True)
server_thread.start()
server_ready.wait()
# Build OAuth authorize URL
params = {
"client_id": HANZO_CLIENT_ID,
"redirect_uri": CALLBACK_URI,
"response_type": "code",
"scope": "openid profile email",
"state": state,
}
authorize_url = f"{iam_url}/login/oauth/authorize?{urlencode(params)}"
console.print("Your browser has been opened to visit:\n")
console.print(f" {authorize_url}\n")
webbrowser.open(authorize_url)
# Wait for callback
server_thread.join(timeout=120)
server.server_close()
if auth_result["error"]:
console.print(f"[red]Login failed: {auth_result['error']}[/red]")
return
if not auth_result["code"]:
console.print("[red]Login timed out. Please try again.[/red]")
return
# Exchange authorization code for tokens
token_url = f"{iam_url}/api/login/oauth/access_token"
token_data = urlencode({
"client_id": HANZO_CLIENT_ID,
"code": auth_result["code"],
"grant_type": "authorization_code",
"redirect_uri": CALLBACK_URI,
}).encode()
req = urllib.request.Request(
token_url,
data=token_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
except Exception as e:
console.print(f"[red]Token exchange failed: {e}[/red]")
return
access_token = data.get("access_token", "")
id_token = data.get("id_token", "")
# Decode JWT to get user info
claims = _decode_jwt_claims(id_token or access_token)
user_email = claims.get("email", claims.get("name", ""))
# Save auth
auth = auth_mgr.load_auth()
auth.update({
"token": access_token,
"id_token": id_token,
"email": user_email,
"logged_in": True,
"last_login": datetime.now().isoformat(),
})
auth_mgr.save_auth(auth)
if user_email:
console.print(f"You are now logged in as [{user_email}].")
else:
console.print("You are now logged in.")
console.print("Your credentials have been saved to: ~/.hanzo/auth.json")
def _login_device_code(auth_mgr: AuthManager, headless: bool):
"""Device code login flow (for SSH/headless)."""
import urllib.request
import time
iam_url = _get_iam_url(auth_mgr)
# Step 1: Request device code
device_req_data = json.dumps({
"client_id": HANZO_CLIENT_ID,
"scope": "openid profile email",
}).encode()
req = urllib.request.Request(
f"{iam_url}/api/device/code",
data=device_req_data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
except Exception as e:
console.print(f"[red]Failed to request device code: {e}[/red]")
return
device_code = data["device_code"]
user_code = data["user_code"]
verification_url = data.get("verification_uri", f"{iam_url}/device")
verification_url_complete = data.get(
"verification_uri_complete",
f"{verification_url}?user_code={user_code}"
)
expires_in = data.get("expires_in", 300)
interval = data.get("interval", 5)
# Step 2: Show instructions
console.print(f"\nTo sign in, visit: [cyan]{verification_url}[/cyan]")
console.print(f"Enter code: [bold yellow]{user_code}[/bold yellow]\n")
if not headless:
try:
webbrowser.open(verification_url_complete)
console.print("[dim](Browser opened automatically)[/dim]\n")
except Exception:
pass
# Step 3: Poll for completion
start_time = time.time()
while time.time() - start_time < expires_in:
time.sleep(interval)
poll_data = json.dumps({
"client_id": HANZO_CLIENT_ID,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
}).encode()
poll_req = urllib.request.Request(
f"{iam_url}/api/login/oauth/access_token",
data=poll_data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(poll_req, timeout=30) as resp:
token_data = json.loads(resp.read().decode())
access_token = token_data.get("access_token", "")
id_token = token_data.get("id_token", "")
claims = _decode_jwt_claims(id_token or access_token)
user_email = claims.get("email", claims.get("name", ""))
auth = auth_mgr.load_auth()
auth.update({
"token": access_token,
"id_token": id_token,
"email": user_email,
"logged_in": True,
"last_login": datetime.now().isoformat(),
})
auth_mgr.save_auth(auth)
if user_email:
console.print(f"You are now logged in as [{user_email}].")
else:
console.print("You are now logged in.")
return
except urllib.error.HTTPError as e:
if e.code == 400:
try:
error_data = json.loads(e.read().decode())
error = error_data.get("error", "")
if error == "authorization_pending":
continue
elif error == "slow_down":
interval += 5
continue
elif error == "expired_token":
console.print("[red]Device code expired. Please try again.[/red]")
return
elif error == "access_denied":
console.print("[red]Authentication denied.[/red]")
return
except Exception:
continue
else:
continue
except Exception:
continue
console.print("[red]Authentication timed out. Please try again.[/red]")
@auth_group.command()
@click.pass_context
def logout(ctx):
@@ -198,15 +413,13 @@ def logout(ctx):
return
try:
# Try using hanzoai if available
try:
from hanzoai.auth import HanzoAuth
_ = HanzoAuth # Available for future remote logout
except ImportError:
pass # Local-only auth
# Clear local auth
auth_mgr.save_auth({})
# Clear login state but preserve IAM config
auth = auth_mgr.load_auth()
preserved = {}
for key in ("iam_url", "iam_client_id", "iam_client_secret", "iam_org", "iam_app"):
if key in auth:
preserved[key] = auth[key]
auth_mgr.save_auth(preserved)
console.print("[green]✓[/green] Logged out successfully")
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -24,18 +24,18 @@ class HanzoAuth:
def __init__(
self,
api_key: Optional[str] = None,
base_url: str = "https://iam.hanzo.ai",
base_url: str = "https://hanzo.id",
api_base_url: str = "https://api.hanzo.ai",
):
"""Initialize authentication client.
Args:
api_key: API key (defaults to HANZO_API_KEY env var)
base_url: IAM service URL
base_url: IAM service URL (default: https://hanzo.id)
api_base_url: API service URL
"""
self.api_key = api_key or os.environ.get("HANZO_API_KEY")
self.base_url = base_url
self.base_url = os.environ.get("HANZO_IAM_URL", os.environ.get("IAM_URL", base_url))
self.api_base_url = api_base_url
self._token = None
self._user_info = None
@@ -70,7 +70,7 @@ class HanzoAuth:
json={
"username": email,
"password": password,
"application": "hanzo-cli",
"application": "app-hanzo",
},
)
response.raise_for_status()
@@ -125,7 +125,7 @@ class HanzoAuth:
response = await client.post(
f"{self.base_url}/api/device/code",
json={
"client_id": "hanzo-cli",
"client_id": "app-hanzo",
"scope": "openid profile email",
},
)
@@ -163,7 +163,7 @@ class HanzoAuth:
token_response = await client.post(
f"{self.base_url}/api/device/token",
json={
"client_id": "hanzo-cli",
"client_id": "app-hanzo",
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
@@ -216,7 +216,7 @@ class HanzoAuth:
# Build SSO URL
params = {
"client_id": "hanzo-cli",
"client_id": "app-hanzo",
"redirect_uri": "http://localhost:8899/callback",
"response_type": "code",
"scope": "openid profile email",
@@ -266,7 +266,7 @@ class HanzoAuth:
response = await client.post(
f"{self.base_url}/api/login/oauth/access_token",
json={
"client_id": "hanzo-cli",
"client_id": "app-hanzo",
"client_secret": "hanzo-cli-secret", # Public client
"code": auth_code,
"grant_type": "authorization_code",