mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Generated
+1524
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
+951
-334
File diff suppressed because it is too large
Load Diff
+8
-8
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user