Files
python-sdk/pkg/hanzo-cli/hanzo_cli/auth.py
T
zeekayandHanzo Dev 9a8b4b5c46 fix(cli): pip install hanzo shipped a 10-command CLI, not the 56-command one
`hanzo` depends on `hanzo-cli`, and BOTH declared `[project.scripts] hanzo`.
Entry-point resolution is install-order dependent, so which CLI a user got was
a coin flip — and hanzo-cli kept winning. The result:

  $ hanzo auth login
  Error: No such command 'auth'.

So the "auth login is broken by Cloudflare 1010" story was real but unreachable:
users never got as far as the HTTP call, because the binary they had did not
have an `auth` command at all. Publishing that fix would have changed nothing.

One command, one owner:
- hanzo-cli drops its console script. `hanzo` is the ONE command.
- `paas` and `bot` are mounted into it — the only two groups with no equivalent.
  `kms` is NOT mounted: `hanzo secrets` is a strict superset (audit/grant/
  revoke/rollback/rotate/versions on top of the same get/list/set/delete), and
  two commands for one concern is what we are removing.
- hanzo_cli grows a __main__ so it stays invocable as `python -m hanzo_cli`,
  and its e2e suite targets that instead of whatever `hanzo` resolves to.

Endpoints, one definition:
- models.IAMConfig (the class the package EXPORTS) gains the HIP-0111 endpoint
  properties. config.IAMConfig had them, but nothing imports that one — a
  second same-named class nobody uses is how the legacy path survived.
- config.py + fastapi.py stop hand-assembling paths and read the constants.
- password_login posted to `/oauth/token`, which is not a 404: IAM serves a
  200 text/html SPA catch-all for unregistered paths, so it received a login
  PAGE and json() blew up on HTML. Verified: legacy 200 text/html vs canonical
  401 application/json.

PKCE, because this is a public client:
- browser_login ships no client_secret, so the authorization code was the only
  secret in the flow and it arrives over a plaintext loopback redirect. The SDK
  already accepted code_challenge/code_verifier; the CLI simply never passed
  them. RFC 8252 §8.1 / RFC 7636 S256.

Version, one source:
- pyproject said 0.4.4, __init__ said 0.3.47, cli.py said 0.3.48, and the
  installed binary reported 0.1.0 — four answers, none right. Now resolved
  from the installed distribution.

Verified on a clean venv: entry-point providers 2 -> 1, commands 10 -> 58,
`hanzo --version` 0.1.0 -> 0.4.4, all four endpoints canonical. hanzo-iam
19/19 pass; hanzo-cli's 20 failures are unchanged before and after and are all
live-service auth (no stored token in this environment).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 14:51:42 -07:00

393 lines
12 KiB
Python

"""Authentication and token management for Hanzo CLI.
Credential chain:
1. IAM_CLIENT_ID + IAM_CLIENT_SECRET env vars
2. ~/.hanzo/auth/token.json from `hanzo login`
3. Exit with help message
"""
from __future__ import annotations
import base64
import hashlib
import http.server
import json
import os
import secrets
import sys
import time
import webbrowser
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
import click
from hanzo_iam import IAMClient, IAMConfig
TOKEN_DIR = Path.home() / ".hanzo" / "auth"
TOKEN_FILE = TOKEN_DIR / "token.json"
DEFAULT_IAM_URL = "https://hanzo.id"
DEFAULT_ORG = "hanzo"
DEFAULT_APP = "hanzo-app"
DEFAULT_CLIENT_ID = "hanzo-app"
CALLBACK_PORT = 8399
CALLBACK_PATH = "/callback"
def _save_token(data: dict[str, Any]) -> None:
"""Save token data to disk."""
TOKEN_DIR.mkdir(parents=True, exist_ok=True)
TOKEN_FILE.write_text(json.dumps(data, indent=2))
TOKEN_FILE.chmod(0o600)
def _load_token() -> dict[str, Any] | None:
"""Load stored token from disk."""
if not TOKEN_FILE.exists():
return None
try:
return json.loads(TOKEN_FILE.read_text())
except (json.JSONDecodeError, OSError):
return None
def _clear_token() -> None:
"""Remove stored token."""
if TOKEN_FILE.exists():
TOKEN_FILE.unlink()
def _env(name: str) -> str:
"""Read env var (IAM_*)."""
return os.getenv(name) or ""
def _iam_url() -> str:
return _env("IAM_URL") or DEFAULT_IAM_URL
def _iam_org() -> str:
return _env("IAM_ORG") or DEFAULT_ORG
def _iam_app() -> str:
return _env("IAM_APP") or DEFAULT_APP
def _iam_client_id() -> str:
return _env("IAM_CLIENT_ID") or DEFAULT_CLIENT_ID
def get_client(ctx: click.Context | None = None) -> IAMClient:
"""Build an IAMClient using the credential chain.
1. Env vars IAM_CLIENT_ID + IAM_CLIENT_SECRET
2. Stored bearer token from `hanzo login`
3. Exit with instructions
"""
client_id = _env("IAM_CLIENT_ID")
client_secret = _env("IAM_CLIENT_SECRET")
if client_id and client_secret:
config = IAMConfig(
server_url=_iam_url(),
client_id=client_id,
client_secret=client_secret,
organization=_iam_org(),
application=_iam_app(),
)
return IAMClient(config=config)
# Try stored token
token_data = _load_token()
if token_data and token_data.get("access_token"):
config = IAMConfig(
server_url=token_data.get("server_url", _iam_url()),
client_id=token_data.get("client_id", ""),
client_secret="",
organization=token_data.get("organization", _iam_org()),
application=token_data.get("application", _iam_app()),
)
return IAMClient(
config=config,
bearer_token=token_data["access_token"],
)
click.echo(
"Not authenticated. Run 'hanzo login' or set IAM_CLIENT_ID"
" + IAM_CLIENT_SECRET environment variables.",
err=True,
)
sys.exit(1)
def get_token_info() -> dict[str, Any] | None:
"""Return stored token info for whoami."""
return _load_token()
# =========================================================================
# Browser OAuth Login Flow
# =========================================================================
class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
"""HTTP handler that captures the OAuth callback code."""
code: str | None = None
state: str | None = None
error: str | None = None
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path != CALLBACK_PATH:
self.send_response(404)
self.end_headers()
return
qs = parse_qs(parsed.query)
if "error" in qs:
_OAuthCallbackHandler.error = qs["error"][0]
self._respond("Login failed. You can close this window.", success=False)
return
_OAuthCallbackHandler.code = qs.get("code", [None])[0]
_OAuthCallbackHandler.state = qs.get("state", [None])[0]
self._respond("Login successful! You can close this window.")
def _respond(self, message: str, success: bool = True) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
accent = "#6C5CE7" if success else "#e74c3c"
body = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hanzo</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
background:#0a0a0a;color:#fafafa;
display:flex;align-items:center;justify-content:center;
min-height:100vh;
}}
.card{{
text-align:center;max-width:420px;padding:48px 32px;
background:#111;border:1px solid #222;border-radius:16px;
}}
.logo{{font-size:28px;font-weight:700;letter-spacing:-.5px;margin-bottom:32px;color:#fff}}
.logo span{{color:{accent}}}
.icon{{font-size:48px;margin-bottom:20px}}
h2{{font-size:20px;font-weight:600;margin-bottom:8px;color:#fff}}
.sub{{color:#888;font-size:14px;line-height:1.5;margin-bottom:28px}}
.tagline{{
font-size:13px;color:#555;border-top:1px solid #222;
padding-top:20px;margin-top:8px;
}}
</style>
</head>
<body>
<div class="card">
<div class="logo"><span>&#x25B2;</span> hanzo</div>
<div class="icon">{"&#x2705;" if success else "&#x274C;"}</div>
<h2>{message}</h2>
<p class="sub">You can close this window and return to your terminal.</p>
<p class="tagline">Build something you love.</p>
</div>
</body>
</html>"""
self.wfile.write(body.encode())
def log_message(self, format: str, *args: Any) -> None:
"""Suppress default request logging."""
def browser_login(port: int = CALLBACK_PORT) -> dict[str, Any]:
"""Run the browser OAuth login flow.
Opens the user's browser to the IAM login page, starts a local HTTP
server to receive the callback, exchanges the code for tokens.
Returns:
Token data dict with access_token, etc.
"""
server_url = _iam_url()
org = _iam_org()
app = _iam_app()
client_id = _iam_client_id()
config = IAMConfig(
server_url=server_url,
client_id=client_id,
client_secret="",
organization=org,
application=app,
)
client = IAMClient(config=config)
redirect_uri = f"http://localhost:{port}{CALLBACK_PATH}"
state = secrets.token_urlsafe(32)
# PKCE is not optional here. This is a native app with a public client
# (client_secret=""), so the authorization code is the ONLY secret in the
# flow and it arrives over a plaintext loopback redirect that any other
# local process can race. RFC 8252 §8.1 requires PKCE for exactly this
# shape; without a verifier an intercepted code is directly redeemable.
code_verifier = secrets.token_urlsafe(64)[:128]
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
.decode()
.rstrip("=")
)
auth_url = client.get_authorization_url(
redirect_uri=redirect_uri,
state=state,
scope="openid profile email",
code_challenge=code_challenge,
code_challenge_method="S256",
)
# Reset handler state
_OAuthCallbackHandler.code = None
_OAuthCallbackHandler.state = None
_OAuthCallbackHandler.error = None
server = http.server.HTTPServer(("127.0.0.1", port), _OAuthCallbackHandler)
server.timeout = 120 # 2 minute timeout
click.echo(f"Opening browser to login at {server_url}...")
webbrowser.open(auth_url)
click.echo(f"Waiting for callback on http://localhost:{port}{CALLBACK_PATH}")
# Handle one request (the callback)
while _OAuthCallbackHandler.code is None and _OAuthCallbackHandler.error is None:
server.handle_request()
server.server_close()
if _OAuthCallbackHandler.error:
raise click.ClickException(f"Login failed: {_OAuthCallbackHandler.error}")
if _OAuthCallbackHandler.state != state:
raise click.ClickException("State mismatch — possible CSRF attack.")
code = _OAuthCallbackHandler.code
if not code:
raise click.ClickException("No authorization code received.")
# Exchange code for tokens
tokens = client.exchange_code(
code=code, redirect_uri=redirect_uri, code_verifier=code_verifier
)
client.close()
token_data = {
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"id_token": tokens.id_token,
"token_type": tokens.token_type,
"expires_in": tokens.expires_in,
"scope": tokens.scope,
"server_url": server_url,
"client_id": client_id,
"organization": org,
"application": app,
"login_time": int(time.time()),
}
_save_token(token_data)
return token_data
# =========================================================================
# Password Login Flow (--no-browser)
# =========================================================================
def password_login(
username: str | None = None, password: str | None = None
) -> dict[str, Any]:
"""Login with username/password (no browser).
Uses the OAuth2 Resource Owner Password Credentials (ROPC) grant
to get a JWT directly from the token endpoint.
Returns:
Token data dict with access_token, etc.
"""
import httpx
server_url = _iam_url()
org = _iam_org()
app = _iam_app()
client_id = _iam_client_id()
client_secret = _env("IAM_CLIENT_SECRET")
if not username:
username = click.prompt("Username or email")
if not password:
password = click.prompt("Password", hide_input=True)
# The endpoint comes from IAMConfig, never a literal. The hardcoded
# "/oauth/token" that used to be here is not a 404 — IAM serves a
# 200 text/html SPA catch-all for any unregistered path, so this call
# returned a login PAGE and resp.json() blew up on HTML. A wrong path
# here is silent breakage, which is why there is exactly one definition.
config = IAMConfig(
server_url=server_url,
client_id=client_id,
client_secret=client_secret,
organization=org,
application=app,
)
resp = httpx.post(
config.token_endpoint,
data={
"grant_type": "password",
"client_id": client_id,
"client_secret": client_secret,
"username": username,
"password": password,
"scope": "openid profile email",
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
)
data = resp.json()
if "error" in data:
raise click.ClickException(data.get("error_description", data["error"]))
access_token = data.get("access_token", "")
if not access_token:
raise click.ClickException("No access token in response")
token_data = {
"access_token": access_token,
"refresh_token": data.get("refresh_token", ""),
"id_token": data.get("id_token", ""),
"token_type": data.get("token_type", "Bearer"),
"expires_in": data.get("expires_in", 0),
"scope": data.get("scope", ""),
"server_url": server_url,
"client_id": client_id,
"organization": org,
"application": app,
"login_time": int(time.time()),
}
_save_token(token_data)
return token_data
def logout() -> None:
"""Clear stored credentials."""
_clear_token()