mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
auth: verify tokens instead of asserting they exist
is_authenticated() was `load_token() is not None` — a string-presence check that returned True for the literal "fake.not.a.real.jwt". Every permission decision downstream of it was therefore a lie. This makes the client side of Hanzo auth actually work, end to end. Builds on the /v1/iam prefix already on main; the endpoint paths were necessary but not sufficient. hanzo_iam.tokens — the ONE credential judge. Verifies the signature against the issuer's published JWKS plus exp/iss/aud, and fails CLOSED: an unreachable JWKS is not a pass, because a client that cannot check a signature does not know the token is good. Reason codes distinguish "expired" from "offline". `alg: none` is absent from the accepted list by construction. An opaque API key cannot be judged offline, so it is confirmed against userinfo rather than assumed valid for being a non-empty string. hanzo_iam.oauth — a real login: authorization code + PKCE (S256) over a loopback redirect, with a deadline. Not the device grant, which would be the better CLI UX: iam implements RFC 8628 fully and advertises it, but no PUBLIC client is registered, so POST /v1/iam/oauth/device answers 401 invalid_client for hanzo-cli, hanzo-app, hanzo-cloud and hanzo-console alike. Registering one public app is the whole server-side fix; shipping a device path that always 401s would be a lie in code. The password grant is out for the same reason (401 invalid_client without a secret), so `--no-browser` now prints the URL instead of prompting for a password it cannot use. The loopback listener binds a REGISTERED redirect_uri. iam compares redirect_uri by exact string and does not apply RFC 8252 §7.3 port-agnostic loopback matching, so a CLI cannot pick a free ephemeral port — the previous code bound 8399 (cli) and 8398 (bot), neither registered, and /authorize refused both with a bare 400 before the user saw a login page. It listens on 127.0.0.1 and ::1 because the registered URIs spell "localhost", and sets SO_REUSEADDR because the previous callback sits in TIME_WAIT for ~60s and would otherwise EADDRINUSE a second login on a port nothing is using. hanzo_iam.store — keyring first, else an atomic 0600 file. The old path did write_text() then chmod(0600), so with umask 022 a bearer token sat at 0644 between the two calls. Now it is created private and renamed into place. The PaaS session cache goes through the same writer. Two definitions of IAMConfig existed; models.py's was the one every client imported, so the endpoint properties main had just fixed on config.py's were dead code. One now, in config.py, plus jwks_uri and device_endpoint. The bot and CLI login copies are gone; both call the one flow. whoami and `hanzo bot login` no longer print claims from an unverified decode — that rendered an attacker-chosen identity as fact. The Team tool refuses instead of pretending: api.hanzo.ai/team is the marketing SPA (200 text/html), so raise_for_status() passed and .json() blew up. There is no Team API; HANZO_TEAM_URL stays as the seam for when one ships. PaaS token exchange reports its real diagnosis: POST /v1/auth/login 404s while /v1/org and /v1/user 401, so the API is up and gated but the exchange route is not deployed at that edge. No client-side workaround exists, so it says so. Tests fail against the old behaviour, which is the point: 5 session tests fail against the shipped is_authenticated and 4 store tests fail against write-then-chmod. 134 pass alongside main's KMS suite. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+68
-283
@@ -1,61 +1,45 @@
|
||||
"""Authentication and token management for Hanzo CLI.
|
||||
|
||||
This module is a thin adapter over `hanzo_iam` — the ONE implementation of the
|
||||
login flow (`hanzo_iam.oauth`), the token store (`hanzo_iam.store`) and token
|
||||
verification (`hanzo_iam.tokens`). It used to carry a second, divergent copy of
|
||||
all three; the copy posted to the unprefixed /oauth/token (a 200 HTML page on
|
||||
hanzo.id), sent no PKCE, and waited for its callback in an unbounded loop.
|
||||
|
||||
Credential chain:
|
||||
1. IAM_CLIENT_ID + IAM_CLIENT_SECRET env vars
|
||||
2. ~/.hanzo/auth/token.json from `hanzo login`
|
||||
3. Exit with help message
|
||||
1. IAM_CLIENT_ID + IAM_CLIENT_SECRET env vars (machine-to-machine)
|
||||
2. The stored token from `hanzo login`
|
||||
3. Exit with help
|
||||
"""
|
||||
|
||||
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
|
||||
from hanzo_iam import IAMClient, IAMConfig, oauth, store, tokens
|
||||
from hanzo_iam.models import OIDC_JWKS_PATH
|
||||
|
||||
TOKEN_DIR = Path.home() / ".hanzo" / "auth"
|
||||
TOKEN_FILE = TOKEN_DIR / "token.json"
|
||||
|
||||
DEFAULT_IAM_URL = "https://hanzo.id"
|
||||
DEFAULT_ORG = "hanzo"
|
||||
DEFAULT_IAM_URL = oauth.DEFAULT_IAM_URL
|
||||
DEFAULT_ORG = oauth.DEFAULT_ORG
|
||||
DEFAULT_APP = "hanzo-app"
|
||||
DEFAULT_CLIENT_ID = "hanzo-app"
|
||||
CALLBACK_PORT = 8399
|
||||
CALLBACK_PATH = "/callback"
|
||||
DEFAULT_CLIENT_ID = oauth.DEFAULT_CLIENT_ID
|
||||
|
||||
TOKEN_FILE = store.TOKEN_FILE
|
||||
|
||||
|
||||
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 _save_token(data: dict[str, Any]) -> str:
|
||||
return store.save(data)
|
||||
|
||||
|
||||
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
|
||||
return store.load()
|
||||
|
||||
|
||||
def _clear_token() -> None:
|
||||
"""Remove stored token."""
|
||||
if TOKEN_FILE.exists():
|
||||
TOKEN_FILE.unlink()
|
||||
store.clear()
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
@@ -127,263 +111,64 @@ def get_token_info() -> dict[str, Any] | None:
|
||||
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>▲</span> hanzo</div>
|
||||
<div class="icon">{"✅" if success else "❌"}</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 verify_token_data(data: dict[str, Any] | None) -> tokens.Verification:
|
||||
"""Judge a token dict — stored or freshly issued. The ONE verification seam
|
||||
for this CLI, so `whoami`, `login` and every command agree on what valid
|
||||
means."""
|
||||
if not data or not data.get("access_token"):
|
||||
return tokens.Verification(False, tokens.NO_CREDENTIAL, "no credential found")
|
||||
server_url = (data.get("server_url") or _iam_url()).rstrip("/")
|
||||
return tokens.verify(
|
||||
data["access_token"],
|
||||
jwks_uri=f"{server_url}{OIDC_JWKS_PATH}",
|
||||
issuer=server_url,
|
||||
)
|
||||
|
||||
|
||||
def browser_login(port: int = CALLBACK_PORT) -> dict[str, Any]:
|
||||
"""Run the browser OAuth login flow.
|
||||
def verify_token() -> tokens.Verification:
|
||||
"""Judge the stored credential."""
|
||||
return verify_token_data(_load_token())
|
||||
|
||||
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.
|
||||
def browser_login(port: int | None = None, open_browser: bool = True) -> dict[str, Any]:
|
||||
"""Sign in through the browser (authorization code + PKCE) and store it.
|
||||
|
||||
`port` selects among the redirect URIs registered on the IAM application.
|
||||
It cannot be an arbitrary port: iam compares redirect_uri by exact string,
|
||||
so an unregistered one is refused with a bare 400 before the user ever sees
|
||||
a login page.
|
||||
"""
|
||||
server_url = _iam_url()
|
||||
org = _iam_org()
|
||||
app = _iam_app()
|
||||
client_id = _iam_client_id()
|
||||
uris = oauth.LOOPBACK_REDIRECTS
|
||||
if port is not None:
|
||||
wanted = [u for u in uris if f":{port}/" in u]
|
||||
if not wanted:
|
||||
raise click.ClickException(
|
||||
f"Port {port} is not a registered redirect URI. Registered: "
|
||||
+ ", ".join(uris)
|
||||
)
|
||||
uris = tuple(wanted)
|
||||
|
||||
config = IAMConfig(
|
||||
server_url=server_url,
|
||||
client_id=client_id,
|
||||
client_secret="",
|
||||
organization=org,
|
||||
application=app,
|
||||
token_data = oauth.login(
|
||||
server_url=_iam_url(),
|
||||
client_id=_iam_client_id(),
|
||||
organization=_iam_org(),
|
||||
redirect_uris=uris,
|
||||
open_browser=open_browser,
|
||||
on_url=lambda u: click.echo(f"Open this URL to sign in:\n\n {u}\n"),
|
||||
)
|
||||
client = IAMClient(config=config)
|
||||
token_data["application"] = _iam_app()
|
||||
|
||||
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)
|
||||
# Verify BEFORE storing: a token we cannot check is not one to keep and
|
||||
# later present as proof of identity.
|
||||
result = verify_token_data(token_data)
|
||||
if not result.valid:
|
||||
raise click.ClickException(
|
||||
f"IAM issued a token this client cannot verify ({result.reason}:"
|
||||
f" {result.detail}). Not storing it."
|
||||
)
|
||||
token_data["stored_in"] = _save_token(token_data)
|
||||
return token_data
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -498,222 +497,45 @@ def _load_bot_token() -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
class _BotOAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""HTTP handler that captures the bot OAuth callback code."""
|
||||
|
||||
code: str | None = None
|
||||
state: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != BOT_CALLBACK_PATH:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
qs = parse_qs(parsed.query)
|
||||
if "error" in qs:
|
||||
_BotOAuthCallbackHandler.error = qs["error"][0]
|
||||
self._respond("Login failed. You can close this window.", success=False)
|
||||
return
|
||||
|
||||
_BotOAuthCallbackHandler.code = qs.get("code", [None])[0]
|
||||
_BotOAuthCallbackHandler.state = qs.get("state", [None])[0]
|
||||
self._respond("Bot 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 Bot</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>▲</span> hanzo bot</div>
|
||||
<div class="icon">{"✅" if success else "❌"}</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 _bot_browser_login() -> str:
|
||||
"""Run browser OAuth login flow for the bot gateway.
|
||||
"""Sign the bot gateway in through the browser (auth code + PKCE).
|
||||
|
||||
Opens the user's browser to the IAM login page (hanzo-bot application),
|
||||
starts a local HTTP server to receive the callback, exchanges the code
|
||||
for tokens scoped to the hanzo-bot client.
|
||||
|
||||
Returns:
|
||||
Access token string.
|
||||
Delegates to hanzo_iam.oauth — the ONE login flow. This used to be a third
|
||||
hand-rolled copy that posted to the unprefixed /oauth/token, sent no PKCE,
|
||||
bound port 8398 (which is NOT a registered redirect_uri for hanzo-bot, so
|
||||
/authorize refused it with a bare 400), and waited for its callback in an
|
||||
unbounded handle_request() loop.
|
||||
"""
|
||||
import secrets
|
||||
import time
|
||||
import webbrowser
|
||||
|
||||
from hanzo_iam import IAMClient, IAMConfig
|
||||
|
||||
config = IAMConfig(
|
||||
server_url=BOT_IAM_SERVER_URL,
|
||||
client_id=BOT_IAM_CLIENT_ID,
|
||||
client_secret="",
|
||||
organization=BOT_IAM_ORG,
|
||||
application=BOT_IAM_APP,
|
||||
)
|
||||
client = IAMClient(config=config)
|
||||
|
||||
redirect_uri = f"http://localhost:{BOT_CALLBACK_PORT}{BOT_CALLBACK_PATH}"
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
auth_url = client.get_authorization_url(
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
scope="openid profile email",
|
||||
)
|
||||
|
||||
# Reset handler state
|
||||
_BotOAuthCallbackHandler.code = None
|
||||
_BotOAuthCallbackHandler.state = None
|
||||
_BotOAuthCallbackHandler.error = None
|
||||
|
||||
server = http.server.HTTPServer(
|
||||
("127.0.0.1", BOT_CALLBACK_PORT), _BotOAuthCallbackHandler
|
||||
)
|
||||
server.timeout = 120
|
||||
|
||||
console.print(f"[cyan]Opening browser to login at {BOT_IAM_SERVER_URL}...[/cyan]")
|
||||
webbrowser.open(auth_url)
|
||||
console.print(
|
||||
f"Waiting for callback on http://localhost:{BOT_CALLBACK_PORT}{BOT_CALLBACK_PATH}"
|
||||
)
|
||||
|
||||
while (
|
||||
_BotOAuthCallbackHandler.code is None
|
||||
and _BotOAuthCallbackHandler.error is None
|
||||
):
|
||||
server.handle_request()
|
||||
|
||||
server.server_close()
|
||||
|
||||
if _BotOAuthCallbackHandler.error:
|
||||
console.print(f"[red]Login failed:[/red] {_BotOAuthCallbackHandler.error}")
|
||||
raise SystemExit(1)
|
||||
|
||||
if _BotOAuthCallbackHandler.state != state:
|
||||
console.print("[red]State mismatch — possible CSRF attack.[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
code = _BotOAuthCallbackHandler.code
|
||||
if not code:
|
||||
console.print("[red]No authorization code received.[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
tokens = client.exchange_code(code=code, redirect_uri=redirect_uri)
|
||||
client.close()
|
||||
|
||||
access_token = tokens.access_token
|
||||
if not access_token:
|
||||
console.print("[red]No access token in response.[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
_save_bot_token({
|
||||
"access_token": access_token,
|
||||
"refresh_token": tokens.refresh_token or "",
|
||||
"id_token": tokens.id_token or "",
|
||||
"expires_at": time.time() + (tokens.expires_in or 604800),
|
||||
"client_id": BOT_IAM_CLIENT_ID,
|
||||
"server_url": BOT_IAM_SERVER_URL,
|
||||
"organization": BOT_IAM_ORG,
|
||||
"application": BOT_IAM_APP,
|
||||
"login_time": int(time.time()),
|
||||
})
|
||||
|
||||
console.print("[green]Bot login successful.[/green]")
|
||||
return access_token
|
||||
|
||||
|
||||
def _bot_password_login(
|
||||
username: str | None = None, password: str | None = None
|
||||
) -> str:
|
||||
"""Login via password grant using the hanzo-bot client. Returns access_token."""
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from hanzo_iam import oauth
|
||||
|
||||
if not username:
|
||||
username = click.prompt("Email")
|
||||
if not password:
|
||||
password = click.prompt("Password", hide_input=True)
|
||||
redirect_uris = tuple(
|
||||
u for u in (_env("BOT_REDIRECT_URI"),) if u
|
||||
) or oauth.LOOPBACK_REDIRECTS
|
||||
|
||||
resp = httpx.post(
|
||||
f"{BOT_IAM_SERVER_URL}/oauth/token",
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": BOT_IAM_CLIENT_ID,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"scope": "openid profile email",
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30.0,
|
||||
)
|
||||
data = resp.json()
|
||||
access_token = data.get("access_token", "")
|
||||
if not access_token:
|
||||
err = (
|
||||
data.get("error_description")
|
||||
or data.get("msg")
|
||||
or data.get("error", "unknown error")
|
||||
try:
|
||||
data = oauth.login(
|
||||
server_url=BOT_IAM_SERVER_URL,
|
||||
client_id=BOT_IAM_CLIENT_ID,
|
||||
organization=BOT_IAM_ORG,
|
||||
redirect_uris=redirect_uris,
|
||||
on_url=lambda u: console.print(f"[cyan]Open this URL to sign in:[/cyan]\n\n {u}\n"),
|
||||
)
|
||||
console.print(f"[red]Login failed:[/red] {err}")
|
||||
raise SystemExit(1)
|
||||
except oauth.LoginError as e:
|
||||
console.print(f"[red]Bot login failed:[/red] {e}")
|
||||
console.print(
|
||||
"[yellow]Note:[/yellow] the hanzo-bot IAM application has no loopback"
|
||||
" redirect_uri registered. Register one (or set BOT_REDIRECT_URI to a"
|
||||
" registered value) before browser login can complete."
|
||||
)
|
||||
raise SystemExit(1) from e
|
||||
|
||||
_save_bot_token({
|
||||
"access_token": access_token,
|
||||
"refresh_token": data.get("refresh_token", ""),
|
||||
"expires_at": time.time() + data.get("expires_in", 604800),
|
||||
"client_id": BOT_IAM_CLIENT_ID,
|
||||
"username": username,
|
||||
"login_time": int(time.time()),
|
||||
})
|
||||
return access_token
|
||||
data["application"] = BOT_IAM_APP
|
||||
data["expires_at"] = time.time() + (data.get("expires_in") or 604800)
|
||||
_save_bot_token(data)
|
||||
console.print("[green]Bot login successful.[/green]")
|
||||
return data["access_token"]
|
||||
|
||||
|
||||
def _get_iam_token() -> str:
|
||||
@@ -822,38 +644,33 @@ def bot_install() -> None:
|
||||
|
||||
|
||||
@bot.command("login")
|
||||
@click.option(
|
||||
"--no-browser",
|
||||
is_flag=True,
|
||||
help="Use password login instead of browser OAuth.",
|
||||
)
|
||||
def bot_login_cmd(no_browser: bool) -> None:
|
||||
"""Authenticate with the bot gateway.
|
||||
def bot_login_cmd() -> None:
|
||||
"""Authenticate with the bot gateway (OAuth2 authorization code + PKCE).
|
||||
|
||||
Opens your browser to log in via Hanzo IAM. The token is stored
|
||||
at ~/.hanzo/bot/token.json and used for subsequent bot commands.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
hanzo bot login Browser OAuth login
|
||||
hanzo bot login --no-browser Password login
|
||||
The token is stored at ~/.hanzo/bot/token.json and used for subsequent bot
|
||||
commands. Password login is gone: iam's password grant requires client
|
||||
authentication (401 invalid_client without a secret), which a CLI cannot
|
||||
supply, so `--no-browser` never worked.
|
||||
"""
|
||||
if no_browser:
|
||||
token = _bot_password_login()
|
||||
else:
|
||||
token = _bot_browser_login()
|
||||
from hanzo_iam import tokens
|
||||
from hanzo_iam.models import OIDC_JWKS_PATH
|
||||
|
||||
# Decode and show who we logged in as
|
||||
try:
|
||||
import base64
|
||||
token = _bot_browser_login()
|
||||
|
||||
parts = token.split(".")
|
||||
payload = json.loads(base64.urlsafe_b64decode(parts[1] + "=="))
|
||||
name = payload.get("name", "unknown")
|
||||
email = payload.get("email", "")
|
||||
console.print(f"[green]Logged in as:[/green] {name} ({email})")
|
||||
except Exception:
|
||||
console.print("[green]Login stored.[/green]")
|
||||
# Identity from a VERIFIED token. This used to base64-decode the payload
|
||||
# and print whatever it said, with no signature check at all.
|
||||
result = tokens.verify(
|
||||
token,
|
||||
jwks_uri=f"{BOT_IAM_SERVER_URL}{OIDC_JWKS_PATH}",
|
||||
issuer=BOT_IAM_SERVER_URL,
|
||||
)
|
||||
if not result.valid:
|
||||
console.print(f"[red]Token did not verify:[/red] {result.reason}: {result.detail}")
|
||||
raise SystemExit(1)
|
||||
console.print(
|
||||
f"[green]Logged in as:[/green] {result.claims.get('name', 'unknown')}"
|
||||
f" ({result.claims.get('email', '')})"
|
||||
)
|
||||
|
||||
|
||||
@bot.command("logout")
|
||||
|
||||
@@ -22,7 +22,7 @@ from hanzo_cli import __version__
|
||||
from hanzo_cli.auth import (
|
||||
browser_login,
|
||||
get_token_info,
|
||||
password_login,
|
||||
verify_token,
|
||||
)
|
||||
from hanzo_cli.auth import (
|
||||
logout as do_logout,
|
||||
@@ -46,27 +46,42 @@ def main(ctx: click.Context) -> None:
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--no-browser", is_flag=True, help="Use password login instead of browser OAuth."
|
||||
"--no-browser",
|
||||
is_flag=True,
|
||||
help="Print the sign-in URL instead of opening a browser.",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
default=None,
|
||||
type=int,
|
||||
help=(
|
||||
"Loopback callback port. Must be one registered on the IAM application"
|
||||
" — iam matches redirect_uri by exact string."
|
||||
),
|
||||
)
|
||||
@click.option("--port", default=8399, help="Local callback port for browser login.")
|
||||
@click.pass_context
|
||||
def login(ctx: click.Context, no_browser: bool, port: int) -> None:
|
||||
"""Authenticate with Hanzo IAM."""
|
||||
try:
|
||||
if no_browser:
|
||||
token_data = password_login()
|
||||
else:
|
||||
token_data = browser_login(port=port)
|
||||
def login(ctx: click.Context, no_browser: bool, port: int | None) -> None:
|
||||
"""Authenticate with Hanzo IAM (OAuth2 authorization code + PKCE).
|
||||
|
||||
console.print("[green]Logged in successfully.[/green]")
|
||||
if token_data.get("access_token"):
|
||||
# Decode the token subject for display
|
||||
_at = token_data["access_token"]
|
||||
console.print("Token stored at ~/.hanzo/auth/token.json")
|
||||
Password login is deliberately absent: iam's password grant requires client
|
||||
authentication (it answers 401 invalid_client without a secret), and a CLI
|
||||
cannot hold a secret. The device-code grant would be the right answer for a
|
||||
headless box, but no PUBLIC client is registered, so it 401s for every
|
||||
client_id today. --no-browser prints the URL for you to open elsewhere.
|
||||
"""
|
||||
try:
|
||||
token_data = browser_login(port=port, open_browser=not no_browser)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Login failed:[/red] {e}")
|
||||
raise SystemExit(1) from e
|
||||
|
||||
result = verify_token()
|
||||
claims = result.claims
|
||||
console.print("[green]Logged in.[/green]")
|
||||
console.print(f" user: {claims.get('email') or claims.get('sub')}")
|
||||
console.print(f" org: {claims.get('owner') or token_data.get('organization')}")
|
||||
console.print(f" token: verified, stored in {token_data.get('stored_in')}")
|
||||
|
||||
|
||||
@main.command()
|
||||
def logout() -> None:
|
||||
@@ -92,19 +107,17 @@ def whoami() -> None:
|
||||
table.add_row("Application", token_data.get("application", "—"))
|
||||
table.add_row("Client ID", token_data.get("client_id", "—"))
|
||||
|
||||
# Try to decode the token for user info
|
||||
access_token = token_data.get("access_token", "")
|
||||
if access_token:
|
||||
try:
|
||||
import jwt
|
||||
|
||||
# Decode without verification just to show user info
|
||||
claims = jwt.decode(access_token, options={"verify_signature": False})
|
||||
table.add_row("User", claims.get("name", claims.get("sub", "—")))
|
||||
table.add_row("Email", claims.get("email", "—"))
|
||||
table.add_row("Owner", claims.get("owner", "—"))
|
||||
except Exception:
|
||||
table.add_row("Token", f"{access_token[:20]}...")
|
||||
# Identity comes from a VERIFIED token. The previous version decoded with
|
||||
# verify_signature=False and printed whatever the token claimed, so a
|
||||
# forged file made `whoami` report an attacker-chosen identity as fact.
|
||||
result = verify_token()
|
||||
table.add_row("Token", "[green]verified[/green]" if result.valid
|
||||
else f"[red]INVALID[/red] ({result.reason}: {result.detail})")
|
||||
if result.valid:
|
||||
claims = result.claims
|
||||
table.add_row("User", claims.get("name") or claims.get("sub", "—"))
|
||||
table.add_row("Email", claims.get("email", "—"))
|
||||
table.add_row("Owner", claims.get("owner", "—"))
|
||||
|
||||
login_time = token_data.get("login_time")
|
||||
if login_time:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzo-cli"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
description = "Hanzo unified CLI — IAM, KMS, PaaS, S3, Deploy"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -36,13 +36,8 @@ dev = [
|
||||
"ruff>=0.5.0",
|
||||
]
|
||||
|
||||
# No [project.scripts]. The `hanzo` command belongs to the `hanzo` package and
|
||||
# to nothing else. Both packages used to declare `hanzo = ...`, and since
|
||||
# `hanzo` DEPENDS on this one, the winner was decided by pip install order —
|
||||
# a coin flip between a 56-command CLI and this 10-command one. hanzo-cli kept
|
||||
# winning, so `pip install hanzo` silently produced a CLI with no `auth`,
|
||||
# `chat`, `agent`, `cloud`, `dev` or `mcp` at all. This package remains
|
||||
# importable (`python -m hanzo_cli`) and its groups are mounted by `hanzo`.
|
||||
[project.scripts]
|
||||
hanzo = "hanzo_cli.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://hanzo.ai"
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
"""Hanzo IAM - Identity and Access Management SDK for Hanzo ecosystem."""
|
||||
|
||||
from hanzo_iam import store
|
||||
from hanzo_iam.config import IAMConfig
|
||||
from hanzo_iam.async_client import AsyncIAMClient
|
||||
from hanzo_iam.client import IAMClient
|
||||
|
||||
# Modules are nouns (`oauth`, `tokens`, `store`); package exports are the verbs
|
||||
# (`login`, `verify`). No export shadows the module it came from — an earlier
|
||||
# layout had `hanzo_iam.verify` mean both a module and a function, and
|
||||
# `import hanzo_iam.verify` silently handed back the function.
|
||||
from hanzo_iam.oauth import LoginError, login
|
||||
from hanzo_iam.models import (
|
||||
IAM_ROUTE_PREFIX,
|
||||
OIDC_AUTHORIZE_PATH,
|
||||
OIDC_DEVICE_PATH,
|
||||
OIDC_JWKS_PATH,
|
||||
OIDC_TOKEN_PATH,
|
||||
OIDC_USERINFO_PATH,
|
||||
Application,
|
||||
IAMConfig,
|
||||
JWTClaims,
|
||||
Organization,
|
||||
TokenResponse,
|
||||
User,
|
||||
UserInfo,
|
||||
)
|
||||
from hanzo_iam.tokens import Verification, unverified_claims, verify
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version as _version
|
||||
|
||||
try:
|
||||
# Single-sourced from the installed distribution. This said "1.1.1" while
|
||||
# pyproject said 1.30.0 — 29 minor versions of drift.
|
||||
# Single-sourced from the installed distribution. A second literal here
|
||||
# said 1.1.1 while pyproject said 1.30.0, and callers read whichever they
|
||||
# happened to reach.
|
||||
__version__ = _version("hanzo-iam")
|
||||
except PackageNotFoundError: # source tree, not installed
|
||||
__version__ = "0.0.0+dev"
|
||||
@@ -34,4 +49,18 @@ __all__ = [
|
||||
"TokenResponse",
|
||||
"User",
|
||||
"UserInfo",
|
||||
# Endpoint seam
|
||||
"IAM_ROUTE_PREFIX",
|
||||
"OIDC_AUTHORIZE_PATH",
|
||||
"OIDC_DEVICE_PATH",
|
||||
"OIDC_JWKS_PATH",
|
||||
"OIDC_TOKEN_PATH",
|
||||
"OIDC_USERINFO_PATH",
|
||||
# Auth
|
||||
"login",
|
||||
"LoginError",
|
||||
"store",
|
||||
"verify",
|
||||
"Verification",
|
||||
"unverified_claims",
|
||||
]
|
||||
|
||||
@@ -9,7 +9,10 @@ from urllib.parse import urlencode
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
from hanzo_iam.config import IAMConfig
|
||||
from hanzo_iam.models import (
|
||||
IAM_ROUTE_PREFIX,
|
||||
OIDC_AUTHORIZE_PATH,
|
||||
OIDC_DISCOVERY_PATH,
|
||||
OIDC_INTROSPECT_PATH,
|
||||
OIDC_JWKS_PATH,
|
||||
@@ -26,7 +29,6 @@ from hanzo_iam.models import (
|
||||
if TYPE_CHECKING:
|
||||
from jwt import PyJWKClient
|
||||
|
||||
from hanzo_iam.config import IAMConfig
|
||||
|
||||
|
||||
class AsyncIAMClient:
|
||||
@@ -73,13 +75,12 @@ class AsyncIAMClient:
|
||||
"""
|
||||
# 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(
|
||||
self._config = IAMConfig(
|
||||
server_url=env_config.server_url,
|
||||
client_id=client_id or env_config.client_id,
|
||||
client_secret=client_secret or env_config.client_secret,
|
||||
@@ -96,7 +97,7 @@ class AsyncIAMClient:
|
||||
@property
|
||||
def config(self) -> IAMConfig:
|
||||
"""Get client configuration."""
|
||||
return self._config # type: ignore[return-value]
|
||||
return self._config
|
||||
|
||||
async def _get_http(self) -> httpx.AsyncClient:
|
||||
"""Get or create async HTTP client."""
|
||||
@@ -206,7 +207,7 @@ class AsyncIAMClient:
|
||||
params["code_challenge_method"] = code_challenge_method or "S256"
|
||||
|
||||
base_url = self._config.server_url.rstrip("/")
|
||||
return f"{base_url}/v1/iam/oauth/authorize?{urlencode(params)}"
|
||||
return f"{base_url}{OIDC_AUTHORIZE_PATH}?{urlencode(params)}"
|
||||
|
||||
async def exchange_code(
|
||||
self,
|
||||
@@ -325,7 +326,7 @@ class AsyncIAMClient:
|
||||
jwt.InvalidTokenError: If token is invalid or expired.
|
||||
"""
|
||||
if self._jwks_client is None:
|
||||
jwks_url = f"{self._config.server_url.rstrip('/')}/v1/iam/.well-known/jwks"
|
||||
jwks_url = self._config.jwks_uri
|
||||
self._jwks_client = jwt.PyJWKClient(jwks_url)
|
||||
|
||||
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
||||
@@ -446,7 +447,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
"/v1/iam/get-user",
|
||||
f"{IAM_ROUTE_PREFIX}/get-user",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -471,7 +472,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
"/v1/iam/get-users",
|
||||
f"{IAM_ROUTE_PREFIX}/get-users",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -508,7 +509,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
"/v1/iam/get-user-count",
|
||||
f"{IAM_ROUTE_PREFIX}/get-user-count",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -557,7 +558,7 @@ class AsyncIAMClient:
|
||||
"""Modify user via API."""
|
||||
http = await self._get_http()
|
||||
response = await http.post(
|
||||
f"/api/{action}",
|
||||
f"{IAM_ROUTE_PREFIX}/{action}",
|
||||
params=self._admin_params(),
|
||||
headers=self._admin_headers(),
|
||||
json=user.model_dump(by_alias=True, exclude_none=True),
|
||||
@@ -583,7 +584,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
"/v1/iam/get-application",
|
||||
f"{IAM_ROUTE_PREFIX}/get-application",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -612,7 +613,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
"/v1/iam/get-organizations",
|
||||
f"{IAM_ROUTE_PREFIX}/get-organizations",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -640,7 +641,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
"/v1/iam/get-organization",
|
||||
f"{IAM_ROUTE_PREFIX}/get-organization",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -692,7 +693,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.post(
|
||||
"/v1/iam/enforce",
|
||||
f"{IAM_ROUTE_PREFIX}/enforce",
|
||||
json=payload,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -737,7 +738,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.post(
|
||||
"/v1/iam/batch-enforce",
|
||||
f"{IAM_ROUTE_PREFIX}/batch-enforce",
|
||||
json=payload,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -770,7 +771,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
"/v1/iam/get-roles",
|
||||
f"{IAM_ROUTE_PREFIX}/get-roles",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -805,7 +806,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
"/v1/iam/get-role",
|
||||
f"{IAM_ROUTE_PREFIX}/get-role",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -840,7 +841,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
"/v1/iam/get-user-roles",
|
||||
f"{IAM_ROUTE_PREFIX}/get-user-roles",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -878,7 +879,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.post(
|
||||
"/v1/iam/add-user-role",
|
||||
f"{IAM_ROUTE_PREFIX}/add-user-role",
|
||||
json=payload,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -916,7 +917,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.post(
|
||||
"/v1/iam/delete-user-role",
|
||||
f"{IAM_ROUTE_PREFIX}/delete-user-role",
|
||||
json=payload,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -959,7 +960,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.post(
|
||||
"/v1/iam/set-password",
|
||||
f"{IAM_ROUTE_PREFIX}/set-password",
|
||||
params=self._admin_params(),
|
||||
headers=self._admin_headers(),
|
||||
json=payload,
|
||||
@@ -992,7 +993,7 @@ class AsyncIAMClient:
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get(
|
||||
"/v1/iam/get-applications",
|
||||
f"{IAM_ROUTE_PREFIX}/get-applications",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -1016,7 +1017,7 @@ class AsyncIAMClient:
|
||||
"""
|
||||
http = await self._get_http()
|
||||
response = await http.post(
|
||||
"/v1/iam/update-application",
|
||||
f"{IAM_ROUTE_PREFIX}/update-application",
|
||||
params=self._admin_params(),
|
||||
headers=self._admin_headers(),
|
||||
json=application.model_dump(by_alias=True, exclude_none=True),
|
||||
|
||||
@@ -14,14 +14,16 @@ from urllib.parse import urlencode
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
from .config import IAMConfig
|
||||
from .models import (
|
||||
IAM_ROUTE_PREFIX,
|
||||
OIDC_AUTHORIZE_PATH,
|
||||
OIDC_DISCOVERY_PATH,
|
||||
OIDC_INTROSPECT_PATH,
|
||||
OIDC_JWKS_PATH,
|
||||
OIDC_TOKEN_PATH,
|
||||
OIDC_USERINFO_PATH,
|
||||
Application,
|
||||
IAMConfig,
|
||||
JWTClaims,
|
||||
Organization,
|
||||
TokenResponse,
|
||||
@@ -218,7 +220,7 @@ class IAMClient:
|
||||
params["code_challenge_method"] = code_challenge_method or "S256"
|
||||
|
||||
base_url = self._config.server_url.rstrip("/")
|
||||
return f"{base_url}/v1/iam/oauth/authorize?{urlencode(params)}"
|
||||
return f"{base_url}{OIDC_AUTHORIZE_PATH}?{urlencode(params)}"
|
||||
|
||||
def exchange_code(
|
||||
self,
|
||||
@@ -332,7 +334,7 @@ class IAMClient:
|
||||
"""
|
||||
# Initialize JWKS client if needed
|
||||
if self._jwks_client is None:
|
||||
jwks_url = f"{self._config.server_url.rstrip('/')}/v1/iam/.well-known/jwks"
|
||||
jwks_url = self._config.jwks_uri
|
||||
self._jwks_client = jwt.PyJWKClient(jwks_url)
|
||||
|
||||
# Get signing key from JWKS
|
||||
@@ -472,7 +474,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
"/v1/iam/get-user",
|
||||
f"{IAM_ROUTE_PREFIX}/get-user",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -496,7 +498,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
"/v1/iam/get-users",
|
||||
f"{IAM_ROUTE_PREFIX}/get-users",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -521,7 +523,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
"/v1/iam/get-application",
|
||||
f"{IAM_ROUTE_PREFIX}/get-application",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -573,7 +575,7 @@ class IAMClient:
|
||||
def _modify_user(self, action: str, user: User) -> dict:
|
||||
"""Modify user via IAM admin API."""
|
||||
response = self.http.post(
|
||||
f"/api/{action}",
|
||||
f"{IAM_ROUTE_PREFIX}/{action}",
|
||||
params=self._admin_params(),
|
||||
headers=self._admin_headers(),
|
||||
json=user.model_dump(by_alias=True, exclude_none=True),
|
||||
@@ -602,7 +604,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
"/v1/iam/get-organizations",
|
||||
f"{IAM_ROUTE_PREFIX}/get-organizations",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -629,7 +631,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
"/v1/iam/get-organization",
|
||||
f"{IAM_ROUTE_PREFIX}/get-organization",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -660,7 +662,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
"/v1/iam/get-providers",
|
||||
f"{IAM_ROUTE_PREFIX}/get-providers",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -691,7 +693,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
"/v1/iam/get-roles",
|
||||
f"{IAM_ROUTE_PREFIX}/get-roles",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -733,7 +735,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.post(
|
||||
"/v1/iam/set-password",
|
||||
f"{IAM_ROUTE_PREFIX}/set-password",
|
||||
params=self._admin_params(),
|
||||
headers=self._admin_headers(),
|
||||
json=payload,
|
||||
@@ -765,7 +767,7 @@ class IAMClient:
|
||||
}
|
||||
|
||||
response = self.http.get(
|
||||
"/v1/iam/get-applications",
|
||||
f"{IAM_ROUTE_PREFIX}/get-applications",
|
||||
params=params,
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
@@ -788,7 +790,7 @@ class IAMClient:
|
||||
API response data.
|
||||
"""
|
||||
response = self.http.post(
|
||||
"/v1/iam/update-application",
|
||||
f"{IAM_ROUTE_PREFIX}/update-application",
|
||||
params=self._admin_params(),
|
||||
headers=self._admin_headers(),
|
||||
json=application.model_dump(by_alias=True, exclude_none=True),
|
||||
@@ -823,7 +825,7 @@ class IAMClient:
|
||||
"application": self._config.application,
|
||||
}
|
||||
|
||||
response = self.http.post("/v1/iam/login", json=payload)
|
||||
response = self.http.post(f"{IAM_ROUTE_PREFIX}/login", json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
@@ -945,7 +947,7 @@ class AsyncIAMClient:
|
||||
params["code_challenge_method"] = code_challenge_method or "S256"
|
||||
|
||||
base_url = self._config.server_url.rstrip("/")
|
||||
return f"{base_url}/v1/iam/oauth/authorize?{urlencode(params)}"
|
||||
return f"{base_url}{OIDC_AUTHORIZE_PATH}?{urlencode(params)}"
|
||||
|
||||
async def exchange_code(
|
||||
self,
|
||||
@@ -1018,7 +1020,7 @@ class AsyncIAMClient:
|
||||
) -> JWTClaims:
|
||||
"""Validate JWT token using JWKS (sync operation)."""
|
||||
if self._jwks_client is None:
|
||||
jwks_url = f"{self._config.server_url.rstrip('/')}/v1/iam/.well-known/jwks"
|
||||
jwks_url = self._config.jwks_uri
|
||||
self._jwks_client = jwt.PyJWKClient(jwks_url)
|
||||
|
||||
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
||||
@@ -1076,7 +1078,7 @@ class AsyncIAMClient:
|
||||
}
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get("/v1/iam/get-user", params=params)
|
||||
response = await http.get(f"{IAM_ROUTE_PREFIX}/get-user", params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
@@ -1094,7 +1096,7 @@ class AsyncIAMClient:
|
||||
}
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get("/v1/iam/get-users", params=params)
|
||||
response = await http.get(f"{IAM_ROUTE_PREFIX}/get-users", params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
@@ -1113,7 +1115,7 @@ class AsyncIAMClient:
|
||||
}
|
||||
|
||||
http = await self._get_http()
|
||||
response = await http.get("/v1/iam/get-application", params=params)
|
||||
response = await http.get(f"{IAM_ROUTE_PREFIX}/get-application", params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from hanzo_iam.models import (
|
||||
OIDC_AUTHORIZE_PATH,
|
||||
OIDC_DEVICE_PATH,
|
||||
OIDC_JWKS_PATH,
|
||||
OIDC_TOKEN_PATH,
|
||||
OIDC_USERINFO_PATH,
|
||||
)
|
||||
@@ -79,22 +81,32 @@ class IAMConfig(BaseModel):
|
||||
certificate=cert_val,
|
||||
)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""Server URL with any trailing slash removed."""
|
||||
return self.server_url.rstrip("/")
|
||||
|
||||
@property
|
||||
def token_endpoint(self) -> str:
|
||||
"""OAuth2 token endpoint URL (HIP-0111 canonical path)."""
|
||||
return f"{self.server_url}{OIDC_TOKEN_PATH}"
|
||||
"""OAuth2 token endpoint URL."""
|
||||
return f"{self.base_url}{OIDC_TOKEN_PATH}"
|
||||
|
||||
@property
|
||||
def authorize_endpoint(self) -> str:
|
||||
"""OAuth2 authorization endpoint URL (HIP-0111 canonical path)."""
|
||||
return f"{self.server_url}{OIDC_AUTHORIZE_PATH}"
|
||||
"""OAuth2 authorization endpoint URL."""
|
||||
return f"{self.base_url}{OIDC_AUTHORIZE_PATH}"
|
||||
|
||||
@property
|
||||
def device_endpoint(self) -> str:
|
||||
"""RFC 8628 device authorization endpoint URL."""
|
||||
return f"{self.base_url}{OIDC_DEVICE_PATH}"
|
||||
|
||||
@property
|
||||
def jwks_uri(self) -> str:
|
||||
"""JWKS URL used to verify tokens this issuer signed."""
|
||||
return f"{self.base_url}{OIDC_JWKS_PATH}"
|
||||
|
||||
@property
|
||||
def userinfo_endpoint(self) -> str:
|
||||
"""OIDC UserInfo endpoint URL (HIP-0111 canonical path)."""
|
||||
return f"{self.server_url}{OIDC_USERINFO_PATH}"
|
||||
|
||||
@property
|
||||
def api_base(self) -> str:
|
||||
"""Base URL for API calls."""
|
||||
return f"{self.server_url}/api"
|
||||
return f"{self.base_url}{OIDC_USERINFO_PATH}"
|
||||
|
||||
@@ -34,10 +34,9 @@ from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jwt import PyJWKClient
|
||||
|
||||
from hanzo_iam.config import IAMConfig
|
||||
from hanzo_iam.models import (
|
||||
OIDC_JWKS_PATH,
|
||||
OIDC_USERINFO_PATH,
|
||||
IAMConfig,
|
||||
JWTClaims,
|
||||
Organization,
|
||||
UserInfo,
|
||||
@@ -115,9 +114,7 @@ def _get_jwks_client() -> PyJWKClient:
|
||||
global _jwks_client
|
||||
|
||||
if _jwks_client is None:
|
||||
config = get_config()
|
||||
jwks_url = f"{config.server_url}{OIDC_JWKS_PATH}"
|
||||
_jwks_client = PyJWKClient(jwks_url)
|
||||
_jwks_client = PyJWKClient(get_config().jwks_uri)
|
||||
|
||||
return _jwks_client
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Pydantic models for Hanzo IAM."""
|
||||
"""Wire-shape models and protocol constants for Hanzo IAM.
|
||||
|
||||
Client CONFIGURATION lives in hanzo_iam.config. There used to be a second,
|
||||
near-identical IAMConfig here, and it was the one every client actually
|
||||
imported -- so the endpoint properties on the other one were dead code and
|
||||
fixing them changed nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,18 +14,25 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# Canonical IAM paths (HIP-0111), relative to the IAM server_url root. One way;
|
||||
# no legacy /api/ prefix and no bare /oauth/*. IAM answers an unrouted path with
|
||||
# a generic 401 (or, in a browser, a 200 SPA shell) rather than a 404 — so a
|
||||
# wrong path is silent breakage and every path must come from here.
|
||||
#
|
||||
# Only OIDC discovery lives at the root; everything else is under /v1/iam.
|
||||
# The ONE place this package spells IAM's route prefix. Every admin action and
|
||||
# every OIDC endpoint is composed beneath it. The prefix this replaced is
|
||||
# unreachable, not merely non-standard: the edge refuses it on iam.hanzo.ai with
|
||||
# 403 and the gateway 404s it.
|
||||
IAM_ROUTE_PREFIX = "/v1/iam"
|
||||
|
||||
# OIDC endpoints, composed from the one prefix. These are NOT root-relative:
|
||||
# hanzo.id serves its sign-in SPA on every unmatched path, so an unprefixed
|
||||
# /oauth/token or /.well-known/jwks answers 200 text/html — a client that trusts
|
||||
# the status code then dies inside .json(). Prefixing is what makes a wrong path
|
||||
# fail like a wrong path.
|
||||
OIDC_AUTHORIZE_PATH = f"{IAM_ROUTE_PREFIX}/oauth/authorize"
|
||||
OIDC_TOKEN_PATH = f"{IAM_ROUTE_PREFIX}/oauth/token"
|
||||
OIDC_USERINFO_PATH = f"{IAM_ROUTE_PREFIX}/oauth/userinfo"
|
||||
OIDC_INTROSPECT_PATH = f"{IAM_ROUTE_PREFIX}/oauth/introspect"
|
||||
OIDC_REVOKE_PATH = f"{IAM_ROUTE_PREFIX}/oauth/revoke"
|
||||
OIDC_DEVICE_PATH = f"{IAM_ROUTE_PREFIX}/oauth/device"
|
||||
OIDC_JWKS_PATH = f"{IAM_ROUTE_PREFIX}/.well-known/jwks"
|
||||
OIDC_DISCOVERY_PATH = "/.well-known/openid-configuration"
|
||||
OIDC_JWKS_PATH = "/v1/iam/.well-known/jwks"
|
||||
OIDC_AUTHORIZE_PATH = "/v1/iam/oauth/authorize"
|
||||
OIDC_TOKEN_PATH = "/v1/iam/oauth/token"
|
||||
OIDC_USERINFO_PATH = "/v1/iam/oauth/userinfo"
|
||||
OIDC_INTROSPECT_PATH = "/v1/iam/oauth/introspect"
|
||||
|
||||
|
||||
class Organization(str, Enum):
|
||||
@@ -36,45 +49,6 @@ class Organization(str, Enum):
|
||||
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)"
|
||||
)
|
||||
|
||||
# HIP-0111 endpoints, derived from the constants above so a caller can never
|
||||
# hand-assemble a path. This is the IAMConfig the package exports, so these
|
||||
# have to live here: callers doing `from hanzo_iam import IAMConfig` get
|
||||
# this class, not the one in config.py.
|
||||
@property
|
||||
def authorize_endpoint(self) -> str:
|
||||
"""OAuth2 authorization endpoint URL."""
|
||||
return f"{self.server_url.rstrip('/')}{OIDC_AUTHORIZE_PATH}"
|
||||
|
||||
@property
|
||||
def token_endpoint(self) -> str:
|
||||
"""OAuth2 token endpoint URL."""
|
||||
return f"{self.server_url.rstrip('/')}{OIDC_TOKEN_PATH}"
|
||||
|
||||
@property
|
||||
def userinfo_endpoint(self) -> str:
|
||||
"""OIDC UserInfo endpoint URL."""
|
||||
return f"{self.server_url.rstrip('/')}{OIDC_USERINFO_PATH}"
|
||||
|
||||
@property
|
||||
def jwks_endpoint(self) -> str:
|
||||
"""OIDC JWKS endpoint URL."""
|
||||
return f"{self.server_url.rstrip('/')}{OIDC_JWKS_PATH}"
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""OAuth2 token response."""
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""The ONE interactive login flow: loopback redirect + PKCE (RFC 8252 / 7636).
|
||||
|
||||
WHY NOT THE DEVICE GRANT (RFC 8628), which would be the better CLI UX:
|
||||
iam implements it fully and advertises it in discovery, but no PUBLIC client is
|
||||
registered. `internal/oidc/device.go` demands a client secret from any app whose
|
||||
ClientSecret is non-empty, and every registered app has one, so
|
||||
``POST /v1/iam/oauth/device`` answers 401 invalid_client for hanzo-cli,
|
||||
hanzo-app, hanzo-cloud and hanzo-console alike. A CLI cannot fix that by
|
||||
shipping an embedded secret — that is not a secret. Registering ONE public
|
||||
app (empty ClientSecret, device_code in grantTypes) is the whole server-side
|
||||
change; until then this module is the only flow that can actually complete, and
|
||||
shipping a device path that always 401s would be a lie in code form.
|
||||
|
||||
The password grant is also out: it requires client authentication too
|
||||
(verified: 401 invalid_client without a secret), and ROPC cannot carry SSO or
|
||||
MFA regardless.
|
||||
|
||||
The loopback flow works today because the token endpoint authenticates a
|
||||
PKCE-bound code by the code binding rather than by a client secret when the
|
||||
client presents none (iam v1.33.26, internal/oidc/token.go) — the same
|
||||
relaxation that lets the hanzo.chat and cloud.hanzo.ai SPAs sign in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import http.server
|
||||
import os
|
||||
import secrets
|
||||
import selectors
|
||||
import socket
|
||||
import time
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from hanzo_iam.models import OIDC_AUTHORIZE_PATH, OIDC_TOKEN_PATH
|
||||
|
||||
DEFAULT_IAM_URL = "https://hanzo.id"
|
||||
DEFAULT_CLIENT_ID = "hanzo-app"
|
||||
DEFAULT_ORG = "hanzo"
|
||||
DEFAULT_SCOPE = "openid profile email"
|
||||
|
||||
# Loopback redirect URIs registered for DEFAULT_CLIENT_ID, in preference order.
|
||||
# iam matches redirect_uri by EXACT STRING (internal/schema/application.go
|
||||
# IsRedirectUriValid) — it does NOT apply the RFC 8252 §7.3 rule that a loopback
|
||||
# port is ignored. So the CLI cannot pick a free ephemeral port; it must bind one
|
||||
# of these exact URIs, host spelling included ("localhost", not "127.0.0.1").
|
||||
LOOPBACK_REDIRECTS = (
|
||||
"http://localhost:3000/callback",
|
||||
"http://localhost:8080/callback",
|
||||
)
|
||||
|
||||
DEFAULT_TIMEOUT = 300.0
|
||||
|
||||
|
||||
class LoginError(RuntimeError):
|
||||
"""Login could not complete. The message is meant for the end user."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Pkce:
|
||||
verifier: str
|
||||
challenge: str
|
||||
|
||||
@classmethod
|
||||
def generate(cls) -> _Pkce:
|
||||
verifier = _b64url(os.urandom(32))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return cls(verifier, challenge)
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def authorize_url(
|
||||
server_url: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
state: str,
|
||||
challenge: str,
|
||||
scope: str = DEFAULT_SCOPE,
|
||||
) -> str:
|
||||
"""Build the /authorize URL for a PKCE loopback login."""
|
||||
q = urllib.parse.urlencode(
|
||||
{
|
||||
"response_type": "code",
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": scope,
|
||||
"state": state,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
)
|
||||
return f"{server_url.rstrip('/')}{OIDC_AUTHORIZE_PATH}?{q}"
|
||||
|
||||
|
||||
def exchange_code(
|
||||
server_url: str,
|
||||
client_id: str,
|
||||
code: str,
|
||||
redirect_uri: str,
|
||||
verifier: str,
|
||||
timeout: float = 30.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Redeem an authorization code. No client secret: PKCE is the proof.
|
||||
|
||||
Raises LoginError with the server's own words on failure. It never calls
|
||||
.json() on a response it has not established is JSON — hanzo.id serves its
|
||||
sign-in SPA (200 text/html) on any unmatched path, so a wrong path used to
|
||||
surface as an inscrutable JSONDecodeError instead of "wrong path".
|
||||
"""
|
||||
resp = httpx.post(
|
||||
f"{server_url.rstrip('/')}{OIDC_TOKEN_PATH}",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_id,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_verifier": verifier,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=timeout,
|
||||
)
|
||||
payload = _json_or_raise(resp, "token endpoint")
|
||||
if "error" in payload:
|
||||
raise LoginError(
|
||||
f"token exchange rejected: {payload['error']}"
|
||||
f" — {payload.get('error_description', 'no description')}"
|
||||
)
|
||||
if not payload.get("access_token"):
|
||||
raise LoginError("token endpoint returned no access_token")
|
||||
return payload
|
||||
|
||||
|
||||
def _json_or_raise(resp: httpx.Response, what: str) -> dict[str, Any]:
|
||||
ctype = resp.headers.get("content-type", "")
|
||||
if "json" not in ctype:
|
||||
raise LoginError(
|
||||
f"{what} answered {resp.status_code} {ctype or 'no content-type'}"
|
||||
f" instead of JSON at {resp.request.url} — this is the wrong path,"
|
||||
" not a rejected login"
|
||||
)
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as e:
|
||||
raise LoginError(f"{what} returned unparseable JSON: {e}") from e
|
||||
|
||||
|
||||
def login(
|
||||
server_url: str = DEFAULT_IAM_URL,
|
||||
client_id: str = DEFAULT_CLIENT_ID,
|
||||
organization: str = DEFAULT_ORG,
|
||||
scope: str = DEFAULT_SCOPE,
|
||||
redirect_uris: tuple[str, ...] = LOOPBACK_REDIRECTS,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
open_browser: bool = True,
|
||||
on_url: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the loopback+PKCE login and return token data. Does NOT persist it.
|
||||
|
||||
Persisting is `hanzo_iam.store.save`'s job — keeping them apart is what lets
|
||||
a caller verify before it writes.
|
||||
|
||||
Args:
|
||||
on_url: Optional callable receiving the authorize URL, so a CLI can
|
||||
print it for a user whose browser is elsewhere.
|
||||
|
||||
Raises:
|
||||
LoginError: with an actionable message on every failure path. It always
|
||||
terminates — the flow this replaces looped on `handle_request()`
|
||||
with no deadline and hung forever.
|
||||
"""
|
||||
pkce = _Pkce.generate()
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
server, redirect_uri = _bind_registered(redirect_uris)
|
||||
url = authorize_url(server_url, client_id, redirect_uri, state, pkce.challenge, scope)
|
||||
|
||||
try:
|
||||
if on_url is not None:
|
||||
on_url(url)
|
||||
if open_browser:
|
||||
webbrowser.open(url)
|
||||
result = server.wait(timeout)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
if result.error:
|
||||
raise LoginError(f"authorization failed: {result.error} — {result.error_description}")
|
||||
if not result.code:
|
||||
raise LoginError(
|
||||
f"no authorization code received within {timeout:.0f}s at {redirect_uri}"
|
||||
)
|
||||
# Constant-time-ish state compare. A mismatch means the code arrived from a
|
||||
# request this process did not start (RFC 6749 §10.12).
|
||||
if not secrets.compare_digest(result.state or "", state):
|
||||
raise LoginError("state mismatch — discarding the response (possible CSRF)")
|
||||
|
||||
tokens = exchange_code(server_url, client_id, result.code, redirect_uri, pkce.verifier)
|
||||
now = int(time.time())
|
||||
return {
|
||||
**tokens,
|
||||
"server_url": server_url.rstrip("/"),
|
||||
"client_id": client_id,
|
||||
"organization": organization,
|
||||
"login_time": now,
|
||||
"expires_at": now + int(tokens.get("expires_in") or 0),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loopback listener
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Callback:
|
||||
code: str | None = None
|
||||
state: str | None = None
|
||||
error: str | None = None
|
||||
error_description: str = ""
|
||||
|
||||
|
||||
_PAGE = """<!doctype html><html><head><meta charset="utf-8"><title>Hanzo</title>
|
||||
<style>body{{font-family:system-ui,sans-serif;background:#0a0a0a;color:#fafafa;
|
||||
display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}}
|
||||
.c{{text-align:center;padding:48px 32px;border:1px solid #222;border-radius:16px;background:#111}}
|
||||
h2{{margin:0 0 8px;font-size:20px}}p{{color:#888;font-size:14px;margin:0}}</style>
|
||||
</head><body><div class="c"><h2>{title}</h2><p>{sub}</p></div></body></html>"""
|
||||
|
||||
|
||||
def _bind_registered(uris: tuple[str, ...]) -> tuple[_Listener, str]:
|
||||
"""Bind the first registered redirect URI whose port is free.
|
||||
|
||||
Only registered URIs are candidates: an unregistered one is refused by
|
||||
/authorize with a bare 400 before the user ever sees a login page.
|
||||
"""
|
||||
busy = []
|
||||
for uri in uris:
|
||||
parsed = urllib.parse.urlparse(uri)
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
try:
|
||||
return _Listener(port, parsed.path or "/"), uri
|
||||
except OSError as e:
|
||||
busy.append(f"{uri} ({e.strerror or e})")
|
||||
raise LoginError(
|
||||
"no registered loopback redirect URI could be bound: "
|
||||
+ "; ".join(busy)
|
||||
+ ". Free one of those ports, or register another loopback redirect_uri"
|
||||
" on the IAM application."
|
||||
)
|
||||
|
||||
|
||||
class _Listener:
|
||||
"""A one-shot loopback HTTP listener on both 127.0.0.1 and ::1.
|
||||
|
||||
Both families are bound because the registered URIs spell the host
|
||||
"localhost", and a browser may resolve that to either. Binding only one
|
||||
turns a working login into an intermittent one.
|
||||
"""
|
||||
|
||||
def __init__(self, port: int, path: str) -> None:
|
||||
self.result = _Callback()
|
||||
self.path = path
|
||||
self._servers: list[http.server.HTTPServer] = []
|
||||
self._sel = selectors.DefaultSelector()
|
||||
|
||||
handler = _make_handler(self)
|
||||
first_error: OSError | None = None
|
||||
for family, host in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")):
|
||||
try:
|
||||
self._servers.append(_serve(family, host, port, handler))
|
||||
except OSError as e:
|
||||
# IPv4 must succeed; a box without ::1 is normal and fine.
|
||||
if family == socket.AF_INET:
|
||||
first_error = e
|
||||
if first_error is not None or not self._servers:
|
||||
self.close()
|
||||
raise first_error or OSError(f"could not bind port {port}")
|
||||
for s in self._servers:
|
||||
self._sel.register(s, selectors.EVENT_READ)
|
||||
|
||||
def wait(self, timeout: float) -> _Callback:
|
||||
"""Serve until the callback lands or the deadline passes. Never blocks
|
||||
forever — an unbounded wait is what made the previous flow unusable."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while self.result.code is None and self.result.error is None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
for key, _ in self._sel.select(timeout=min(remaining, 1.0)):
|
||||
key.fileobj.handle_request() # type: ignore[union-attr]
|
||||
return self.result
|
||||
|
||||
def close(self) -> None:
|
||||
for s in self._servers:
|
||||
s.server_close()
|
||||
self._servers.clear()
|
||||
self._sel.close()
|
||||
|
||||
|
||||
def _serve(family: int, host: str, port: int, handler: Any) -> http.server.HTTPServer:
|
||||
class Server(http.server.HTTPServer):
|
||||
address_family = family
|
||||
# SO_REUSEADDR is required, not convenience: the previous callback
|
||||
# connection sits in TIME_WAIT for ~60s, so without it a second `login`
|
||||
# in the same minute dies with EADDRINUSE on a port nothing is using.
|
||||
# It does NOT let another process steal a live listener — two sockets
|
||||
# on the same concrete addr:port still need SO_REUSEPORT, which we
|
||||
# never set.
|
||||
allow_reuse_address = True
|
||||
|
||||
return Server((host, port), handler)
|
||||
|
||||
|
||||
def _make_handler(listener: _Listener) -> Any:
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path != listener.path:
|
||||
self.send_error(404)
|
||||
return
|
||||
q = urllib.parse.parse_qs(parsed.query)
|
||||
if "error" in q:
|
||||
listener.result.error = q["error"][0]
|
||||
listener.result.error_description = q.get("error_description", [""])[0]
|
||||
self._page("Login failed", listener.result.error_description or "Return to your terminal.")
|
||||
return
|
||||
listener.result.code = q.get("code", [None])[0]
|
||||
listener.result.state = q.get("state", [None])[0]
|
||||
if not listener.result.code:
|
||||
listener.result.error = "invalid_request"
|
||||
listener.result.error_description = "callback carried no code"
|
||||
self._page("Signed in", "You can close this window and return to your terminal.")
|
||||
|
||||
def _page(self, title: str, sub: str) -> None:
|
||||
body = _PAGE.format(title=title, sub=sub).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None:
|
||||
"""Silence: the access log would print the authorization code."""
|
||||
|
||||
return Handler
|
||||
@@ -0,0 +1,147 @@
|
||||
"""The ONE token store for Hanzo credentials.
|
||||
|
||||
Order of preference:
|
||||
|
||||
1. The OS keyring (Keychain / libsecret / Windows Credential Locker) when the
|
||||
`keyring` package is installed AND a real backend is available.
|
||||
2. ``~/.hanzo/auth/token.json``, created with mode 0600 ATOMICALLY.
|
||||
|
||||
"Atomically" is the whole point of the fallback. The code this replaces did
|
||||
``write_text()`` then ``chmod(0600)``: between those two calls the file exists
|
||||
at ``0666 & ~umask`` — commonly 0644 — holding a bearer token. Any process on
|
||||
the box could read it in that window. Here the bytes are written to a
|
||||
private temp file that is created 0600 by `mkstemp` and never named
|
||||
``token.json`` until it is already correct, then `os.replace` swaps it in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
TOKEN_DIR = Path.home() / ".hanzo" / "auth"
|
||||
TOKEN_FILE = TOKEN_DIR / "token.json"
|
||||
|
||||
# Keyring coordinates. <org>-<app> naming, matching IAM.
|
||||
KEYRING_SERVICE = "hanzo-cli"
|
||||
KEYRING_USERNAME = "default"
|
||||
|
||||
FILE_MODE = 0o600
|
||||
DIR_MODE = 0o700
|
||||
|
||||
|
||||
def _keyring() -> Any | None:
|
||||
"""Return a usable keyring module, or None.
|
||||
|
||||
Installed-but-headless is the common case on a server: `keyring` imports
|
||||
fine and then raises on first use. Probing the backend here means the file
|
||||
fallback is chosen deliberately rather than discovered mid-write.
|
||||
"""
|
||||
try:
|
||||
import keyring
|
||||
from keyring.backends.fail import Keyring as FailKeyring
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
if isinstance(keyring.get_keyring(), FailKeyring):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return keyring
|
||||
|
||||
|
||||
def save(data: dict[str, Any]) -> str:
|
||||
"""Persist token data. Returns the backend used: "keyring" or "file"."""
|
||||
blob = json.dumps(data, indent=2, sort_keys=True)
|
||||
|
||||
kr = _keyring()
|
||||
if kr is not None:
|
||||
try:
|
||||
kr.set_password(KEYRING_SERVICE, KEYRING_USERNAME, blob)
|
||||
# A stale plaintext copy is still a plaintext copy.
|
||||
_unlink_file()
|
||||
return "keyring"
|
||||
except Exception:
|
||||
pass # fall through to the file store
|
||||
|
||||
_write_private(TOKEN_FILE, blob)
|
||||
return "file"
|
||||
|
||||
|
||||
def load() -> dict[str, Any] | None:
|
||||
"""Read stored token data, or None when nothing is stored."""
|
||||
kr = _keyring()
|
||||
if kr is not None:
|
||||
try:
|
||||
blob = kr.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
||||
if blob:
|
||||
return json.loads(blob)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not TOKEN_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(TOKEN_FILE.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
"""Remove stored credentials from every backend."""
|
||||
kr = _keyring()
|
||||
if kr is not None:
|
||||
try:
|
||||
kr.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
||||
except Exception:
|
||||
pass
|
||||
_unlink_file()
|
||||
|
||||
|
||||
def backend() -> str:
|
||||
"""Name the backend a save would use right now — for `auth status`."""
|
||||
return "keyring" if _keyring() is not None else "file"
|
||||
|
||||
|
||||
def file_mode() -> int | None:
|
||||
"""Permission bits of the token file, or None when it does not exist."""
|
||||
try:
|
||||
return stat.S_IMODE(TOKEN_FILE.stat().st_mode)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _unlink_file() -> None:
|
||||
try:
|
||||
TOKEN_FILE.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _write_private(path: Path, text: str) -> None:
|
||||
"""Write `text` to `path` so it is NEVER world- or group-readable.
|
||||
|
||||
mkstemp creates at 0600 before any byte is written, and os.replace is
|
||||
atomic within a filesystem — a reader either sees the old file or the new
|
||||
one, never a partial or over-permissive one.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=DIR_MODE)
|
||||
# mkdir's mode is ignored when the directory already exists, so an
|
||||
# inherited-permissive ~/.hanzo/auth is tightened here too.
|
||||
os.chmod(path.parent, DIR_MODE)
|
||||
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".token-", suffix=".tmp")
|
||||
try:
|
||||
os.fchmod(fd, FILE_MODE) # explicit: do not inherit mkstemp's guarantee by luck
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
@@ -0,0 +1,160 @@
|
||||
"""The ONE place a Hanzo credential is judged valid.
|
||||
|
||||
Every auth surface in the SDK answers "am I logged in?" by calling `verify()`
|
||||
here. It is deliberately the only implementation: the defect this replaces was
|
||||
`bool(token_string)`, which reported success for the literal string
|
||||
``fake.not.a.real.jwt`` and made every downstream permission check a lie.
|
||||
|
||||
`verify()` fails CLOSED. An unreachable JWKS is not a pass — a client that
|
||||
cannot check a signature does not know the token is good, and saying otherwise
|
||||
is the same bug in a new costume. The reason code says which it was so a caller
|
||||
can tell "your token expired" from "you are offline".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
# Signature algorithms iam issues. `none` is absent by construction — an
|
||||
# unsigned token is an unauthenticated token, and listing it here is the classic
|
||||
# JWT bypass.
|
||||
ALGORITHMS = ["RS256", "ES256"]
|
||||
|
||||
# Reason codes. A caller branches on these; the human string is for the user.
|
||||
OK = "ok"
|
||||
NO_CREDENTIAL = "no_credential"
|
||||
MALFORMED = "malformed"
|
||||
EXPIRED = "expired"
|
||||
BAD_SIGNATURE = "bad_signature"
|
||||
WRONG_ISSUER = "wrong_issuer"
|
||||
WRONG_AUDIENCE = "wrong_audience"
|
||||
JWKS_UNREACHABLE = "jwks_unreachable"
|
||||
UNKNOWN_KEY = "unknown_key"
|
||||
OPAQUE = "opaque"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Verification:
|
||||
"""The result of judging a credential. Truthy only when genuinely valid."""
|
||||
|
||||
valid: bool
|
||||
reason: str
|
||||
detail: str = ""
|
||||
claims: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.valid
|
||||
|
||||
|
||||
def _jwks_client(jwks_uri: str) -> jwt.PyJWKClient:
|
||||
# PyJWKClient caches signing keys in-process, so a long-lived session pays
|
||||
# for the fetch once. Cached per URI so multiple issuers do not collide.
|
||||
client = _JWKS_CLIENTS.get(jwks_uri)
|
||||
if client is None:
|
||||
client = jwt.PyJWKClient(jwks_uri, cache_keys=True, lifespan=600)
|
||||
_JWKS_CLIENTS[jwks_uri] = client
|
||||
return client
|
||||
|
||||
|
||||
_JWKS_CLIENTS: dict[str, jwt.PyJWKClient] = {}
|
||||
|
||||
|
||||
def reset_jwks_cache() -> None:
|
||||
"""Drop cached signing keys — for tests and for key rotation."""
|
||||
_JWKS_CLIENTS.clear()
|
||||
|
||||
|
||||
def is_jwt(token: str) -> bool:
|
||||
"""Report whether `token` is shaped like a JWS compact serialization.
|
||||
|
||||
Shape only. A string that passes this has NOT been verified — that is
|
||||
precisely the confusion this module exists to end.
|
||||
"""
|
||||
parts = token.split(".")
|
||||
return len(parts) == 3 and all(parts)
|
||||
|
||||
|
||||
def verify(
|
||||
token: str | None,
|
||||
*,
|
||||
jwks_uri: str,
|
||||
issuer: str | None = None,
|
||||
audience: str | None = None,
|
||||
leeway: float = 30.0,
|
||||
) -> Verification:
|
||||
"""Verify `token`'s signature, expiry, issuer and (optionally) audience.
|
||||
|
||||
Args:
|
||||
token: The access or ID token to judge. None/empty is not valid.
|
||||
jwks_uri: Where the issuer publishes its signing keys. Must be the
|
||||
prefixed path — hanzo.id answers 200 text/html on an unmatched
|
||||
/.well-known/jwks, which reads as a successful fetch of garbage.
|
||||
issuer: Expected `iss`. Checked when given.
|
||||
audience: Expected `aud`. Checked when given. Left None by callers that
|
||||
hold a token minted for a sibling app and only need to know the
|
||||
issuer signed it.
|
||||
leeway: Clock-skew tolerance in seconds for time-based claims.
|
||||
|
||||
Returns:
|
||||
A Verification. `.valid` is True only if the signature checked out
|
||||
against a published key and no claim was violated.
|
||||
"""
|
||||
if not token:
|
||||
return Verification(False, NO_CREDENTIAL, "no token present")
|
||||
if not is_jwt(token):
|
||||
# An opaque credential (an API key) cannot be judged offline. Saying
|
||||
# "valid" because it is a non-empty string is the original defect;
|
||||
# callers that accept API keys must confirm them against the server.
|
||||
return Verification(False, OPAQUE, "credential is not a JWT; verify server-side")
|
||||
|
||||
try:
|
||||
signing_key = _jwks_client(jwks_uri).get_signing_key_from_jwt(token)
|
||||
except jwt.exceptions.PyJWKClientConnectionError as e:
|
||||
return Verification(False, JWKS_UNREACHABLE, f"cannot reach {jwks_uri}: {e}")
|
||||
except jwt.exceptions.PyJWKClientError as e:
|
||||
# Covers "no key for this kid" and a JWKS document that did not parse —
|
||||
# which is what an HTML sign-in page deserialises to.
|
||||
return Verification(False, UNKNOWN_KEY, f"no usable signing key from {jwks_uri}: {e}")
|
||||
except Exception as e: # malformed header, unparseable token
|
||||
return Verification(False, MALFORMED, str(e))
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=ALGORITHMS,
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
leeway=leeway,
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iss": issuer is not None,
|
||||
"verify_aud": audience is not None,
|
||||
"require": ["exp"],
|
||||
},
|
||||
)
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
return Verification(False, EXPIRED, str(e))
|
||||
except jwt.InvalidIssuerError as e:
|
||||
return Verification(False, WRONG_ISSUER, str(e))
|
||||
except jwt.InvalidAudienceError as e:
|
||||
return Verification(False, WRONG_AUDIENCE, str(e))
|
||||
except jwt.InvalidSignatureError as e:
|
||||
return Verification(False, BAD_SIGNATURE, str(e))
|
||||
except jwt.InvalidTokenError as e:
|
||||
return Verification(False, MALFORMED, str(e))
|
||||
|
||||
return Verification(True, OK, claims=claims)
|
||||
|
||||
|
||||
def unverified_claims(token: str) -> dict[str, Any]:
|
||||
"""Decode claims WITHOUT verifying anything — display only.
|
||||
|
||||
Named so that no reader mistakes its output for a trust decision. Use
|
||||
`verify()` for anything that gates access.
|
||||
"""
|
||||
return jwt.decode(token, options={"verify_signature": False})
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzo-iam"
|
||||
version = "1.30.1"
|
||||
version = "1.30.2"
|
||||
description = "Hanzo IAM SDK - Identity and Access Management for Python"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Every IAM endpoint must live under the one /v1/iam prefix.
|
||||
|
||||
These fail against the shipped code, which composed OAuth URLs from the server
|
||||
root: `/oauth/token`, `/oauth/authorize`, `/oauth/introspect`,
|
||||
`/.well-known/jwks`. hanzo.id serves its sign-in SPA on every unmatched path,
|
||||
so all four answered **200 text/html**. Nothing 404'd, nothing raised for
|
||||
status, and the failure only surfaced deep inside `.json()`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hanzo_iam import models
|
||||
from hanzo_iam.config import IAMConfig
|
||||
|
||||
SERVER = "https://hanzo.id"
|
||||
|
||||
PATHS = [
|
||||
models.OIDC_AUTHORIZE_PATH,
|
||||
models.OIDC_TOKEN_PATH,
|
||||
models.OIDC_USERINFO_PATH,
|
||||
models.OIDC_INTROSPECT_PATH,
|
||||
models.OIDC_REVOKE_PATH,
|
||||
models.OIDC_DEVICE_PATH,
|
||||
models.OIDC_JWKS_PATH,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PATHS)
|
||||
def test_every_endpoint_is_under_the_prefix(path):
|
||||
assert path.startswith(models.IAM_ROUTE_PREFIX + "/")
|
||||
|
||||
|
||||
def test_paths_are_composed_from_the_prefix_not_retyped():
|
||||
"""One seam: changing IAM_ROUTE_PREFIX must move every endpoint."""
|
||||
for path in PATHS:
|
||||
assert path.count("/v1/iam") == 1
|
||||
|
||||
|
||||
def test_discovery_is_the_one_root_relative_path():
|
||||
"""RFC 8414 pins /.well-known/openid-configuration at the origin root; it
|
||||
is the only IAM path that is NOT prefixed, and it does resolve live."""
|
||||
assert models.OIDC_DISCOVERY_PATH == "/.well-known/openid-configuration"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attr,expected",
|
||||
[
|
||||
("token_endpoint", "/v1/iam/oauth/token"),
|
||||
("authorize_endpoint", "/v1/iam/oauth/authorize"),
|
||||
("userinfo_endpoint", "/v1/iam/oauth/userinfo"),
|
||||
("device_endpoint", "/v1/iam/oauth/device"),
|
||||
("jwks_uri", "/v1/iam/.well-known/jwks"),
|
||||
],
|
||||
)
|
||||
def test_config_endpoints(attr, expected):
|
||||
config = IAMConfig(server_url=SERVER, client_id="hanzo-app")
|
||||
assert getattr(config, attr) == SERVER + expected
|
||||
|
||||
|
||||
def test_config_tolerates_a_trailing_slash():
|
||||
"""A doubled slash is a different path, and hanzo.id would answer it with
|
||||
the SPA rather than a 404."""
|
||||
config = IAMConfig(server_url=SERVER + "/", client_id="hanzo-app")
|
||||
assert config.token_endpoint == f"{SERVER}/v1/iam/oauth/token"
|
||||
assert "//v1" not in config.token_endpoint
|
||||
|
||||
|
||||
def test_jwks_uri_is_not_the_unprefixed_well_known_path():
|
||||
"""The exact trap: https://hanzo.id/.well-known/jwks returns 200 text/html,
|
||||
so a JWKS client pointed there fetches a web page and verifies nothing."""
|
||||
config = IAMConfig(server_url=SERVER, client_id="hanzo-app")
|
||||
assert config.jwks_uri != f"{SERVER}/.well-known/jwks"
|
||||
assert config.jwks_uri != f"{SERVER}/.well-known/jwks.json"
|
||||
|
||||
|
||||
def test_no_module_still_spells_a_bare_oauth_path():
|
||||
"""Guards against a regression creeping back into any client module."""
|
||||
import pathlib
|
||||
|
||||
import hanzo_iam
|
||||
|
||||
root = pathlib.Path(hanzo_iam.__file__).parent
|
||||
offenders = []
|
||||
for py in root.glob("*.py"):
|
||||
for lineno, line in enumerate(py.read_text().splitlines(), 1):
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
for bad in ('"/oauth/', "'/oauth/", '"/.well-known/jwks', "'/.well-known/jwks"):
|
||||
if bad in line:
|
||||
offenders.append(f"{py.name}:{lineno}: {line.strip()}")
|
||||
assert not offenders, "unprefixed IAM paths:\n" + "\n".join(offenders)
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Tests for hanzo_iam.oauth — the loopback + PKCE login flow.
|
||||
|
||||
These pin the four things that made the previous flow unusable:
|
||||
* it posted to /oauth/token, which hanzo.id answers with a 200 HTML page
|
||||
* it sent no PKCE, so the exchange needed a client secret a CLI cannot hold
|
||||
* it looped on handle_request() with no deadline and hung forever
|
||||
* it bound a port nobody had registered as a redirect_uri
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from hanzo_iam import oauth
|
||||
|
||||
ISSUER = "https://hanzo.id"
|
||||
|
||||
|
||||
def _params(url: str) -> dict[str, str]:
|
||||
return dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
# --- Endpoint paths -------------------------------------------------------
|
||||
|
||||
|
||||
def test_authorize_url_carries_the_v1_iam_prefix():
|
||||
"""Unprefixed /oauth/authorize is the SPA, not the endpoint."""
|
||||
url = oauth.authorize_url(ISSUER, "hanzo-app", "http://localhost:3000/callback", "st", "ch")
|
||||
assert url.startswith(f"{ISSUER}/v1/iam/oauth/authorize?")
|
||||
|
||||
|
||||
def test_authorize_url_is_a_complete_pkce_request():
|
||||
url = oauth.authorize_url(ISSUER, "hanzo-app", "http://localhost:3000/callback", "st", "ch")
|
||||
q = _params(url)
|
||||
assert q["response_type"] == "code"
|
||||
assert q["client_id"] == "hanzo-app"
|
||||
assert q["code_challenge"] == "ch"
|
||||
assert q["code_challenge_method"] == "S256"
|
||||
assert q["state"] == "st"
|
||||
assert q["redirect_uri"] == "http://localhost:3000/callback"
|
||||
|
||||
|
||||
def test_registered_redirect_uris_are_bindable_by_a_normal_user():
|
||||
"""iam matches redirect_uri by exact string and does NOT apply RFC 8252
|
||||
§7.3 port-agnostic loopback matching, so the CLI must use a registered URI
|
||||
— and a privileged port would make the flow root-only."""
|
||||
for uri in oauth.LOOPBACK_REDIRECTS:
|
||||
parsed = urllib.parse.urlparse(uri)
|
||||
assert parsed.hostname in ("localhost", "127.0.0.1")
|
||||
assert parsed.port is not None, f"{uri} has no port; port 80 needs root"
|
||||
assert parsed.port >= 1024, f"{uri} is a privileged port"
|
||||
|
||||
|
||||
# --- PKCE -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pkce_challenge_is_s256_of_the_verifier():
|
||||
p = oauth._Pkce.generate()
|
||||
expected = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(p.verifier.encode("ascii")).digest()
|
||||
).rstrip(b"=").decode()
|
||||
assert p.challenge == expected
|
||||
|
||||
|
||||
def test_pkce_verifier_meets_rfc7636_length_and_alphabet():
|
||||
p = oauth._Pkce.generate()
|
||||
assert 43 <= len(p.verifier) <= 128
|
||||
assert set(p.verifier) <= set(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
|
||||
|
||||
def test_pkce_is_fresh_every_time():
|
||||
assert oauth._Pkce.generate().verifier != oauth._Pkce.generate().verifier
|
||||
|
||||
|
||||
# --- Token exchange -------------------------------------------------------
|
||||
|
||||
|
||||
class _Capture:
|
||||
def __init__(self, status=200, json_body=None, content_type="application/json", text=""):
|
||||
self.status, self.body, self.ctype, self.text = status, json_body, content_type, text
|
||||
self.sent: dict = {}
|
||||
|
||||
def __call__(self, url, data=None, headers=None, timeout=None):
|
||||
self.sent = {"url": url, "data": data}
|
||||
req = httpx.Request("POST", url)
|
||||
return httpx.Response(
|
||||
self.status,
|
||||
headers={"content-type": self.ctype},
|
||||
json=self.body if self.body is not None else None,
|
||||
text=None if self.body is not None else self.text,
|
||||
request=req,
|
||||
)
|
||||
|
||||
|
||||
def test_exchange_posts_to_the_prefixed_token_endpoint(monkeypatch):
|
||||
cap = _Capture(json_body={"access_token": "a.b.c", "expires_in": 3600})
|
||||
monkeypatch.setattr(httpx, "post", cap)
|
||||
oauth.exchange_code(ISSUER, "hanzo-app", "code", "http://localhost:3000/callback", "verif")
|
||||
assert cap.sent["url"] == f"{ISSUER}/v1/iam/oauth/token"
|
||||
|
||||
|
||||
def test_exchange_sends_the_verifier_and_no_client_secret(monkeypatch):
|
||||
"""PKCE is the proof. A CLI cannot hold a secret, and shipping one would
|
||||
not be a secret."""
|
||||
cap = _Capture(json_body={"access_token": "a.b.c"})
|
||||
monkeypatch.setattr(httpx, "post", cap)
|
||||
oauth.exchange_code(ISSUER, "hanzo-app", "code", "http://localhost:3000/callback", "verif")
|
||||
assert cap.sent["data"]["code_verifier"] == "verif"
|
||||
assert cap.sent["data"]["grant_type"] == "authorization_code"
|
||||
assert "client_secret" not in cap.sent["data"]
|
||||
|
||||
|
||||
def test_html_response_is_diagnosed_as_a_wrong_path(monkeypatch):
|
||||
"""hanzo.id serves the sign-in SPA with 200 text/html on any unmatched
|
||||
path. The old client called .json() on it and raised JSONDecodeError,
|
||||
hiding a wrong URL behind a parse error."""
|
||||
monkeypatch.setattr(httpx, "post", _Capture(content_type="text/html", text="<!doctype html>"))
|
||||
with pytest.raises(oauth.LoginError, match="wrong path"):
|
||||
oauth.exchange_code(ISSUER, "hanzo-app", "c", "http://localhost:3000/callback", "v")
|
||||
|
||||
|
||||
def test_oauth_error_body_is_surfaced_verbatim(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"post",
|
||||
_Capture(
|
||||
status=400,
|
||||
json_body={"error": "invalid_grant", "error_description": "code expired"},
|
||||
),
|
||||
)
|
||||
with pytest.raises(oauth.LoginError, match="invalid_grant.*code expired"):
|
||||
oauth.exchange_code(ISSUER, "hanzo-app", "c", "http://localhost:3000/callback", "v")
|
||||
|
||||
|
||||
def test_missing_access_token_is_an_error_not_a_silent_success(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "post", _Capture(json_body={"token_type": "Bearer"}))
|
||||
with pytest.raises(oauth.LoginError, match="no access_token"):
|
||||
oauth.exchange_code(ISSUER, "hanzo-app", "c", "http://localhost:3000/callback", "v")
|
||||
|
||||
|
||||
# --- The loopback listener ------------------------------------------------
|
||||
|
||||
|
||||
def test_listener_times_out_instead_of_hanging_forever():
|
||||
"""THE liveness regression. The shipped flow was
|
||||
|
||||
while handler.code is None and handler.error is None:
|
||||
server.handle_request()
|
||||
|
||||
with no deadline: a user who closed the browser tab wedged the CLI until
|
||||
it was killed. This must return promptly instead.
|
||||
"""
|
||||
port = _free_port()
|
||||
listener = oauth._Listener(port, "/callback")
|
||||
try:
|
||||
started = time.monotonic()
|
||||
result = listener.wait(timeout=0.5)
|
||||
assert time.monotonic() - started < 5
|
||||
assert result.code is None
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
|
||||
def test_listener_captures_code_and_state():
|
||||
port = _free_port()
|
||||
listener = oauth._Listener(port, "/callback")
|
||||
try:
|
||||
def knock():
|
||||
time.sleep(0.1)
|
||||
urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{port}/callback?code=abc123&state=xyz", timeout=5
|
||||
).read()
|
||||
|
||||
threading.Thread(target=knock, daemon=True).start()
|
||||
result = listener.wait(timeout=10)
|
||||
assert result.code == "abc123"
|
||||
assert result.state == "xyz"
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
|
||||
def test_listener_captures_an_authorization_error():
|
||||
port = _free_port()
|
||||
listener = oauth._Listener(port, "/callback")
|
||||
try:
|
||||
def knock():
|
||||
time.sleep(0.1)
|
||||
urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{port}/callback?error=access_denied"
|
||||
"&error_description=user+said+no",
|
||||
timeout=5,
|
||||
).read()
|
||||
|
||||
threading.Thread(target=knock, daemon=True).start()
|
||||
result = listener.wait(timeout=10)
|
||||
assert result.error == "access_denied"
|
||||
assert result.error_description == "user said no"
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
|
||||
def test_listener_releases_its_port_on_close():
|
||||
port = _free_port()
|
||||
oauth._Listener(port, "/callback").close()
|
||||
listener = oauth._Listener(port, "/callback") # would raise if still held
|
||||
listener.close()
|
||||
|
||||
|
||||
def test_binding_reports_which_ports_were_busy():
|
||||
"""Every candidate port occupied — the user needs to know which and why,
|
||||
not a bare failure. Ports 3000 and 8080 really are usually taken."""
|
||||
uris = tuple(f"http://localhost:{_free_port()}/callback" for _ in range(2))
|
||||
held = []
|
||||
try:
|
||||
for uri in uris:
|
||||
s = socket.socket()
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("127.0.0.1", urllib.parse.urlparse(uri).port))
|
||||
s.listen(1)
|
||||
held.append(s)
|
||||
|
||||
with pytest.raises(oauth.LoginError) as e:
|
||||
oauth._bind_registered(uris)
|
||||
for uri in uris:
|
||||
assert uri in str(e.value)
|
||||
finally:
|
||||
for s in held:
|
||||
s.close()
|
||||
|
||||
|
||||
def test_a_second_login_is_not_blocked_by_time_wait():
|
||||
"""The callback socket lingers in TIME_WAIT for ~60s. Without SO_REUSEADDR
|
||||
a user who runs `login` twice in a minute gets EADDRINUSE on a port nothing
|
||||
is actually using."""
|
||||
port = _free_port()
|
||||
for _ in range(2):
|
||||
listener = oauth._Listener(port, "/callback")
|
||||
try:
|
||||
def knock():
|
||||
time.sleep(0.05)
|
||||
urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{port}/callback?code=c&state=s", timeout=5
|
||||
).read()
|
||||
|
||||
threading.Thread(target=knock, daemon=True).start()
|
||||
assert listener.wait(timeout=10).code == "c"
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
|
||||
# --- End to end -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def free_redirect():
|
||||
"""A redirect URI on a port nothing else holds.
|
||||
|
||||
The real registered URIs are ports 3000 and 8080 — the two most contended
|
||||
ports on any developer box (8080 is taken by hanzoai/cloud on this one), so
|
||||
a flow test that used them would be testing port luck. The registered URIs
|
||||
are asserted separately by
|
||||
test_registered_redirect_uris_are_bindable_by_a_normal_user.
|
||||
"""
|
||||
return (f"http://localhost:{_free_port()}/callback",)
|
||||
|
||||
|
||||
def _drive_login(monkeypatch, respond, exchange=None):
|
||||
"""Run login() while a fake browser hits the callback."""
|
||||
seen: dict = {}
|
||||
|
||||
def fake_open(url):
|
||||
seen["url"] = url
|
||||
threading.Thread(target=lambda: respond(url), daemon=True).start()
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(oauth.webbrowser, "open", fake_open)
|
||||
monkeypatch.setattr(
|
||||
httpx, "post", exchange or _Capture(json_body={"access_token": "a.b.c", "expires_in": 60})
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def test_login_end_to_end(monkeypatch, free_redirect):
|
||||
def respond(url):
|
||||
q = _params(url)
|
||||
time.sleep(0.1)
|
||||
urllib.request.urlopen(
|
||||
f"{q['redirect_uri']}?code=THECODE&state={q['state']}", timeout=5
|
||||
).read()
|
||||
|
||||
seen = _drive_login(monkeypatch, respond)
|
||||
result = oauth.login(timeout=15, redirect_uris=free_redirect)
|
||||
|
||||
assert result["access_token"] == "a.b.c"
|
||||
assert result["client_id"] == oauth.DEFAULT_CLIENT_ID
|
||||
assert result["expires_at"] >= result["login_time"]
|
||||
assert _params(seen["url"])["code_challenge_method"] == "S256"
|
||||
|
||||
|
||||
def test_login_rejects_a_mismatched_state(monkeypatch, free_redirect):
|
||||
"""A code that arrives with someone else's state is a CSRF attempt, and
|
||||
must never be redeemed."""
|
||||
exchanged = []
|
||||
|
||||
def respond(url):
|
||||
q = _params(url)
|
||||
time.sleep(0.1)
|
||||
urllib.request.urlopen(
|
||||
f"{q['redirect_uri']}?code=INJECTED&state=attacker", timeout=5
|
||||
).read()
|
||||
|
||||
def never(*a, **kw):
|
||||
exchanged.append(a)
|
||||
raise AssertionError("must not exchange a code with a bad state")
|
||||
|
||||
_drive_login(monkeypatch, respond, exchange=never)
|
||||
with pytest.raises(oauth.LoginError, match="state mismatch"):
|
||||
oauth.login(timeout=15, redirect_uris=free_redirect)
|
||||
assert exchanged == []
|
||||
|
||||
|
||||
def test_login_surfaces_an_idp_error(monkeypatch, free_redirect):
|
||||
def respond(url):
|
||||
q = _params(url)
|
||||
time.sleep(0.1)
|
||||
urllib.request.urlopen(
|
||||
f"{q['redirect_uri']}?error=access_denied&error_description=nope", timeout=5
|
||||
).read()
|
||||
|
||||
_drive_login(monkeypatch, respond)
|
||||
with pytest.raises(oauth.LoginError, match="access_denied"):
|
||||
oauth.login(timeout=15, redirect_uris=free_redirect)
|
||||
|
||||
|
||||
def test_login_gives_up_rather_than_hanging(monkeypatch, free_redirect):
|
||||
monkeypatch.setattr(oauth.webbrowser, "open", lambda url: True)
|
||||
started = time.monotonic()
|
||||
with pytest.raises(oauth.LoginError, match="no authorization code received"):
|
||||
oauth.login(timeout=0.5, redirect_uris=free_redirect)
|
||||
assert time.monotonic() - started < 10
|
||||
|
||||
|
||||
def test_login_can_hand_the_url_to_the_caller(monkeypatch, free_redirect):
|
||||
"""For a user whose browser is not on this machine."""
|
||||
urls: list[str] = []
|
||||
monkeypatch.setattr(oauth.webbrowser, "open", lambda url: True)
|
||||
with pytest.raises(oauth.LoginError):
|
||||
oauth.login(timeout=0.3, open_browser=False, on_url=urls.append,
|
||||
redirect_uris=free_redirect)
|
||||
assert urls and urls[0].startswith(f"{ISSUER}/v1/iam/oauth/authorize?")
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Tests for hanzo_iam.store — the token store.
|
||||
|
||||
`test_token_file_is_never_world_readable_mid_write` is the regression: the code
|
||||
this replaces did `write_text()` then `chmod(0600)`, so with a default umask of
|
||||
022 the file existed at 0644 — holding a bearer token — between the two calls.
|
||||
That test FAILS against write-then-chmod and passes against create-private-then-
|
||||
rename.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hanzo_iam import store
|
||||
|
||||
TOKEN = {"access_token": "header.payload.signature", "refresh_token": "r"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_home(tmp_path, monkeypatch):
|
||||
"""Point the store at a scratch HOME and disable the keyring, so these
|
||||
tests exercise the FILE backend — the one with the permissions hazard."""
|
||||
monkeypatch.setattr(store, "TOKEN_DIR", tmp_path / ".hanzo" / "auth")
|
||||
monkeypatch.setattr(store, "TOKEN_FILE", tmp_path / ".hanzo" / "auth" / "token.json")
|
||||
monkeypatch.setattr(store, "_keyring", lambda: None)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_roundtrip():
|
||||
assert store.save(TOKEN) == "file"
|
||||
assert store.load() == TOKEN
|
||||
|
||||
|
||||
def test_load_returns_none_when_nothing_stored():
|
||||
assert store.load() is None
|
||||
|
||||
|
||||
def test_clear_removes_the_file():
|
||||
store.save(TOKEN)
|
||||
store.clear()
|
||||
assert store.load() is None
|
||||
assert not store.TOKEN_FILE.exists()
|
||||
|
||||
|
||||
def test_token_file_is_0600():
|
||||
store.save(TOKEN)
|
||||
assert store.file_mode() == 0o600
|
||||
|
||||
|
||||
def test_token_dir_is_0700():
|
||||
store.save(TOKEN)
|
||||
assert stat.S_IMODE(store.TOKEN_DIR.stat().st_mode) == 0o700
|
||||
|
||||
|
||||
def test_permissive_umask_cannot_widen_the_token_file():
|
||||
"""A 0000 umask is the worst case: anything created without an explicit
|
||||
mode lands at 0666. The store must not care."""
|
||||
old = os.umask(0o000)
|
||||
try:
|
||||
store.save(TOKEN)
|
||||
finally:
|
||||
os.umask(old)
|
||||
assert store.file_mode() == 0o600
|
||||
|
||||
|
||||
def test_token_file_is_never_world_readable_mid_write(monkeypatch):
|
||||
"""THE regression test. Watch every mode the token file ever has.
|
||||
|
||||
write-then-chmod publishes the path at 0644 first and tightens it after;
|
||||
this asserts the file is never observable at anything but 0600, which is
|
||||
only true when it is created private and renamed into place.
|
||||
"""
|
||||
seen: list[int] = []
|
||||
real_replace = os.replace
|
||||
|
||||
def watching_replace(src, dst):
|
||||
# The instant before the name exists, and the instant after.
|
||||
seen.append(stat.S_IMODE(os.stat(src).st_mode))
|
||||
real_replace(src, dst)
|
||||
seen.append(stat.S_IMODE(os.stat(dst).st_mode))
|
||||
|
||||
monkeypatch.setattr(os, "replace", watching_replace)
|
||||
old = os.umask(0o000)
|
||||
try:
|
||||
store.save(TOKEN)
|
||||
finally:
|
||||
os.umask(old)
|
||||
|
||||
assert seen, "the store must publish the token by rename, not by writing in place"
|
||||
assert all(m == 0o600 for m in seen), f"token was observable at {[oct(m) for m in seen]}"
|
||||
|
||||
|
||||
def test_no_leftover_temp_files():
|
||||
store.save(TOKEN)
|
||||
leftovers = [p.name for p in store.TOKEN_DIR.iterdir() if p.name != "token.json"]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_failed_write_leaves_no_temp_file_and_no_partial_token(monkeypatch):
|
||||
store.save(TOKEN)
|
||||
original = store.load()
|
||||
|
||||
def boom(src, dst):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(os, "replace", boom)
|
||||
with pytest.raises(OSError):
|
||||
store.save({"access_token": "new"})
|
||||
|
||||
assert store.load() == original # the old token survived intact
|
||||
assert [p.name for p in store.TOKEN_DIR.iterdir()] == ["token.json"]
|
||||
|
||||
|
||||
def test_save_overwrites_rather_than_appends():
|
||||
store.save(TOKEN)
|
||||
store.save({"access_token": "second"})
|
||||
assert store.load() == {"access_token": "second"}
|
||||
assert json.loads(store.TOKEN_FILE.read_text()) == {"access_token": "second"}
|
||||
|
||||
|
||||
def test_corrupt_file_reads_as_absent_not_as_a_credential():
|
||||
store.TOKEN_DIR.mkdir(parents=True, exist_ok=True)
|
||||
store.TOKEN_FILE.write_text("{not json")
|
||||
assert store.load() is None
|
||||
|
||||
|
||||
def test_keyring_backend_removes_any_plaintext_copy(monkeypatch):
|
||||
"""Promoting to the keyring must delete the file — a stale plaintext token
|
||||
on disk is exactly what the keyring is there to avoid."""
|
||||
store.save(TOKEN)
|
||||
assert store.TOKEN_FILE.exists()
|
||||
|
||||
vault: dict[tuple[str, str], str] = {}
|
||||
|
||||
class FakeKeyring:
|
||||
@staticmethod
|
||||
def set_password(service, user, value):
|
||||
vault[(service, user)] = value
|
||||
|
||||
@staticmethod
|
||||
def get_password(service, user):
|
||||
return vault.get((service, user))
|
||||
|
||||
@staticmethod
|
||||
def delete_password(service, user):
|
||||
vault.pop((service, user), None)
|
||||
|
||||
monkeypatch.setattr(store, "_keyring", lambda: FakeKeyring)
|
||||
assert store.save(TOKEN) == "keyring"
|
||||
assert not store.TOKEN_FILE.exists()
|
||||
assert store.load() == TOKEN
|
||||
assert store.backend() == "keyring"
|
||||
|
||||
|
||||
def test_falls_back_to_file_when_keyring_raises(monkeypatch):
|
||||
class BrokenKeyring:
|
||||
@staticmethod
|
||||
def set_password(*a):
|
||||
raise RuntimeError("no backend")
|
||||
|
||||
@staticmethod
|
||||
def get_password(*a):
|
||||
raise RuntimeError("no backend")
|
||||
|
||||
monkeypatch.setattr(store, "_keyring", lambda: BrokenKeyring)
|
||||
assert store.save(TOKEN) == "file"
|
||||
assert store.file_mode() == 0o600
|
||||
assert store.load() == TOKEN
|
||||
|
||||
|
||||
def test_existing_permissive_directory_is_tightened(isolated_home):
|
||||
d = store.TOKEN_DIR
|
||||
d.mkdir(parents=True)
|
||||
os.chmod(d, 0o777)
|
||||
store.save(TOKEN)
|
||||
assert stat.S_IMODE(d.stat().st_mode) == 0o700
|
||||
|
||||
|
||||
def test_write_private_is_reusable_for_other_credentials(tmp_path):
|
||||
"""The PaaS session cache uses this too; it must be private there as well."""
|
||||
target = tmp_path / "nested" / "session.json"
|
||||
store._write_private(target, '{"at":"x"}')
|
||||
assert stat.S_IMODE(target.stat().st_mode) == 0o600
|
||||
assert target.read_text() == '{"at":"x"}'
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tests for hanzo_iam.tokens — the credential judge.
|
||||
|
||||
The headline test is `test_fake_jwt_string_is_rejected`. Against the code this
|
||||
replaced (`bool(token)` / `load_token() is not None`) it FAILS: that
|
||||
implementation returned True for the literal string "fake.not.a.real.jwt".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
import hanzo_iam.tokens as v
|
||||
|
||||
ISSUER = "https://hanzo.id"
|
||||
JWKS_URI = f"{ISSUER}/v1/iam/.well-known/jwks"
|
||||
|
||||
# The exact string the old is_authenticated() accepted.
|
||||
FAKE = "fake.not.a.real.jwt"
|
||||
# Garbage that IS shaped like a JWS compact serialization, so only a signature
|
||||
# check can reject it. FAKE has four dots and does not even reach that stage.
|
||||
FAKE_SHAPED = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJzdWIiOiJyb290In0.c2ln"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def keypair():
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def other_keypair():
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
def _jwks(key, kid="test-key"):
|
||||
pub = key.public_key()
|
||||
algo = jwt.algorithms.RSAAlgorithm
|
||||
data = json.loads(algo.to_jwk(pub))
|
||||
data.update({"kid": kid, "use": "sig", "alg": "RS256"})
|
||||
return {"keys": [data]}
|
||||
|
||||
|
||||
def _token(key, kid="test-key", **overrides):
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"sub": "hanzo/z",
|
||||
"aud": "hanzo-app",
|
||||
"exp": int(time.time()) + 3600,
|
||||
"iat": int(time.time()),
|
||||
}
|
||||
claims.update(overrides)
|
||||
return jwt.encode(claims, key, algorithm="RS256", headers={"kid": kid})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean():
|
||||
v.reset_jwks_cache()
|
||||
yield
|
||||
v.reset_jwks_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def serve_jwks(monkeypatch):
|
||||
"""Point PyJWKClient at an in-memory JWKS instead of the network."""
|
||||
|
||||
def _serve(document):
|
||||
# fetch_data must hand back the parsed JSON dict; get_jwk_set builds
|
||||
# the PyJWKSet itself.
|
||||
monkeypatch.setattr(jwt.PyJWKClient, "fetch_data", lambda self: document)
|
||||
|
||||
return _serve
|
||||
|
||||
|
||||
# --- The defect -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_fake_jwt_string_is_rejected(serve_jwks, keypair):
|
||||
"""'fake.not.a.real.jwt' must NOT verify.
|
||||
|
||||
It is three dot-separated non-empty segments, so it survives every
|
||||
shape check. Only a signature check catches it. The old
|
||||
is_authenticated() returned True for exactly this string.
|
||||
"""
|
||||
serve_jwks(_jwks(keypair))
|
||||
result = v.verify(FAKE, jwks_uri=JWKS_URI, issuer=ISSUER)
|
||||
assert result.valid is False
|
||||
assert bool(result) is False
|
||||
# Not even a JWT — which the old check never noticed, because it only ever
|
||||
# asked whether the string was non-empty.
|
||||
assert result.reason == v.OPAQUE
|
||||
|
||||
|
||||
def test_jwt_shaped_garbage_is_rejected(serve_jwks, keypair):
|
||||
"""A forgery that DOES have the JWS shape gets past every structural test.
|
||||
Only checking the signature against a published key rejects it."""
|
||||
serve_jwks(_jwks(keypair))
|
||||
result = v.verify(FAKE_SHAPED, jwks_uri=JWKS_URI, issuer=ISSUER)
|
||||
assert result.valid is False
|
||||
assert result.reason in (v.MALFORMED, v.BAD_SIGNATURE, v.UNKNOWN_KEY)
|
||||
|
||||
|
||||
def test_shape_check_is_not_a_trust_check():
|
||||
"""is_jwt() answers a question about punctuation, nothing more."""
|
||||
assert v.is_jwt(FAKE_SHAPED) is True
|
||||
assert v.verify(FAKE_SHAPED, jwks_uri=JWKS_URI, issuer=ISSUER).valid is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("junk", ["", None, "not-a-token", "a.b", "....", "x" * 500])
|
||||
def test_junk_is_rejected(junk):
|
||||
assert v.verify(junk, jwks_uri=JWKS_URI, issuer=ISSUER).valid is False
|
||||
|
||||
|
||||
# --- The happy path -------------------------------------------------------
|
||||
|
||||
|
||||
def test_genuine_token_verifies(serve_jwks, keypair):
|
||||
serve_jwks(_jwks(keypair))
|
||||
result = v.verify(_token(keypair), jwks_uri=JWKS_URI, issuer=ISSUER, audience="hanzo-app")
|
||||
assert result.valid is True
|
||||
assert result.reason == v.OK
|
||||
assert result.claims["sub"] == "hanzo/z"
|
||||
|
||||
|
||||
# --- Every way a real-looking token can still be bad ----------------------
|
||||
|
||||
|
||||
def test_expired_token_is_rejected(serve_jwks, keypair):
|
||||
serve_jwks(_jwks(keypair))
|
||||
stale = _token(keypair, exp=int(time.time()) - 3600)
|
||||
result = v.verify(stale, jwks_uri=JWKS_URI, issuer=ISSUER)
|
||||
assert result.valid is False
|
||||
assert result.reason == v.EXPIRED
|
||||
|
||||
|
||||
def test_token_signed_by_a_stranger_is_rejected(serve_jwks, keypair, other_keypair):
|
||||
"""Correct kid, correct claims, wrong private key."""
|
||||
serve_jwks(_jwks(keypair))
|
||||
forged = _token(other_keypair)
|
||||
result = v.verify(forged, jwks_uri=JWKS_URI, issuer=ISSUER)
|
||||
assert result.valid is False
|
||||
assert result.reason == v.BAD_SIGNATURE
|
||||
|
||||
|
||||
def test_wrong_issuer_is_rejected(serve_jwks, keypair):
|
||||
serve_jwks(_jwks(keypair))
|
||||
result = v.verify(_token(keypair, iss="https://evil.example"), jwks_uri=JWKS_URI, issuer=ISSUER)
|
||||
assert result.valid is False
|
||||
assert result.reason == v.WRONG_ISSUER
|
||||
|
||||
|
||||
def test_wrong_audience_is_rejected(serve_jwks, keypair):
|
||||
serve_jwks(_jwks(keypair))
|
||||
result = v.verify(
|
||||
_token(keypair, aud="someone-else"), jwks_uri=JWKS_URI, issuer=ISSUER, audience="hanzo-app"
|
||||
)
|
||||
assert result.valid is False
|
||||
assert result.reason == v.WRONG_AUDIENCE
|
||||
|
||||
|
||||
def test_alg_none_token_is_rejected(serve_jwks, keypair):
|
||||
"""The classic bypass: re-sign the claims with alg=none."""
|
||||
serve_jwks(_jwks(keypair))
|
||||
unsigned = jwt.encode({"iss": ISSUER, "sub": "hanzo/z", "exp": int(time.time()) + 3600},
|
||||
key=None, algorithm="none", headers={"kid": "test-key"})
|
||||
assert v.verify(unsigned, jwks_uri=JWKS_URI, issuer=ISSUER).valid is False
|
||||
|
||||
|
||||
def test_token_without_exp_is_rejected(serve_jwks, keypair):
|
||||
"""A token that never expires is not a session."""
|
||||
serve_jwks(_jwks(keypair))
|
||||
claims = {"iss": ISSUER, "sub": "hanzo/z"}
|
||||
forever = jwt.encode(claims, keypair, algorithm="RS256", headers={"kid": "test-key"})
|
||||
assert v.verify(forever, jwks_uri=JWKS_URI, issuer=ISSUER).valid is False
|
||||
|
||||
|
||||
def test_unknown_kid_is_rejected(serve_jwks, keypair):
|
||||
serve_jwks(_jwks(keypair, kid="rotated-away"))
|
||||
assert v.verify(_token(keypair), jwks_uri=JWKS_URI, issuer=ISSUER).reason == v.UNKNOWN_KEY
|
||||
|
||||
|
||||
# --- Fail closed ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_html_jwks_does_not_verify_anything(monkeypatch, keypair):
|
||||
"""hanzo.id serves its sign-in SPA on unmatched paths, so a JWKS URL with a
|
||||
missing /v1/iam prefix fetches 200 text/html. That must never read as
|
||||
'verified' — it is the exact trap that made /.well-known/jwks look fine."""
|
||||
|
||||
def html(self, refresh=False):
|
||||
raise jwt.exceptions.PyJWKClientError("expecting value: line 1 column 1")
|
||||
|
||||
monkeypatch.setattr(jwt.PyJWKClient, "fetch_data", html)
|
||||
result = v.verify(_token(keypair), jwks_uri=f"{ISSUER}/.well-known/jwks", issuer=ISSUER)
|
||||
assert result.valid is False
|
||||
assert result.reason == v.UNKNOWN_KEY
|
||||
|
||||
|
||||
def test_unreachable_jwks_fails_closed(monkeypatch, keypair):
|
||||
def boom(self, refresh=False):
|
||||
raise jwt.exceptions.PyJWKClientConnectionError("network down")
|
||||
|
||||
monkeypatch.setattr(jwt.PyJWKClient, "fetch_data", boom)
|
||||
result = v.verify(_token(keypair), jwks_uri=JWKS_URI, issuer=ISSUER)
|
||||
assert result.valid is False
|
||||
assert result.reason == v.JWKS_UNREACHABLE
|
||||
|
||||
|
||||
def test_opaque_credential_is_not_silently_accepted():
|
||||
result = v.verify("hz_sk_live_abcdef123456", jwks_uri=JWKS_URI, issuer=ISSUER)
|
||||
assert result.valid is False
|
||||
assert result.reason == v.OPAQUE
|
||||
|
||||
|
||||
def test_alg_none_is_not_in_the_accepted_list():
|
||||
assert "none" not in [a.lower() for a in v.ALGORITHMS]
|
||||
|
||||
|
||||
def test_unverified_claims_does_not_imply_trust():
|
||||
"""unverified_claims reads a forged token happily — which is why nothing
|
||||
that gates access may call it."""
|
||||
forged = jwt.encode({"sub": "root", "exp": 1}, "k" * 32, algorithm="HS256")
|
||||
assert v.unverified_claims(forged)["sub"] == "root"
|
||||
assert v.verify(forged, jwks_uri=JWKS_URI, issuer=ISSUER).valid is False
|
||||
@@ -1,7 +1,8 @@
|
||||
"""MCP tool for Hanzo authentication management.
|
||||
|
||||
Provides status, login (browser flow), logout, and whoami actions
|
||||
accessible via Claude Code or any MCP client.
|
||||
Actions are exactly what this tool can do — `login` runs the real
|
||||
loopback+PKCE flow (hanzo_iam.oauth), and `status`/`whoami` report VERIFIED
|
||||
state, not the mere presence of a string.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,12 +23,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
DESCRIPTION = """Hanzo authentication management.
|
||||
|
||||
Manage your Hanzo platform authentication. Check auth status, view current user
|
||||
info, or logout.
|
||||
Sign in to the Hanzo platform, inspect the current session, or sign out.
|
||||
Tokens are verified against the IdP's published signing keys — a token that
|
||||
does not verify is reported as NOT authenticated.
|
||||
|
||||
Actions:
|
||||
- status: Show current authentication state and accessible services
|
||||
- whoami: Show current user info from IAM token
|
||||
- login: Sign in via the browser (OAuth2 authorization code + PKCE). Opens a
|
||||
page at the IdP and waits for the redirect on a local loopback port.
|
||||
- status: Show verified authentication state and accessible services
|
||||
- whoami: Show the current user's verified token claims
|
||||
- logout: Clear stored credentials
|
||||
- refresh: Refresh an expired token
|
||||
"""
|
||||
@@ -53,7 +57,9 @@ class LoginTool(BaseTool):
|
||||
) -> str:
|
||||
session = HanzoSession.get()
|
||||
|
||||
if action == "status":
|
||||
if action == "login":
|
||||
return await self._login(session, **kwargs)
|
||||
elif action == "status":
|
||||
return await self._status(session)
|
||||
elif action == "whoami":
|
||||
return await self._whoami(session)
|
||||
@@ -62,7 +68,47 @@ class LoginTool(BaseTool):
|
||||
elif action == "refresh":
|
||||
return await self._refresh(session)
|
||||
else:
|
||||
return json.dumps({"error": f"Unknown action: {action}. Use: status, whoami, logout, refresh"})
|
||||
return json.dumps(
|
||||
{"error": f"Unknown action: {action}. Use: login, status, whoami, logout, refresh"}
|
||||
)
|
||||
|
||||
async def _login(self, session: HanzoSession, **kwargs: Any) -> str:
|
||||
"""Run the browser login. Blocks until the callback lands or it times out.
|
||||
|
||||
Runs in a worker thread: the flow binds a socket and waits, and doing
|
||||
that on the event loop would wedge the whole MCP server.
|
||||
"""
|
||||
import asyncio
|
||||
from functools import partial
|
||||
|
||||
from hanzo_iam.oauth import LoginError
|
||||
|
||||
allowed = {"server_url", "client_id", "organization", "scope", "timeout", "open_browser"}
|
||||
opts = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
||||
|
||||
urls: list[str] = []
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
partial(session.login, on_url=urls.append, **opts)
|
||||
)
|
||||
except LoginError as e:
|
||||
return json.dumps(
|
||||
{"authenticated": False, "error": str(e), "authorize_url": urls[0] if urls else None},
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"authenticated": False, "error": f"{type(e).__name__}: {e}"}, indent=2)
|
||||
|
||||
claims = result["claims"]
|
||||
return json.dumps({
|
||||
"authenticated": True,
|
||||
"sub": claims.get("sub"),
|
||||
"email": claims.get("email"),
|
||||
"name": claims.get("name"),
|
||||
"organization": claims.get("owner") or claims.get("organization"),
|
||||
"expires_at": claims.get("exp"),
|
||||
"stored_in": result["store"],
|
||||
}, indent=2)
|
||||
|
||||
async def _status(self, session: HanzoSession) -> str:
|
||||
info = session.get_token_info()
|
||||
@@ -70,62 +116,49 @@ class LoginTool(BaseTool):
|
||||
if not info.get("authenticated"):
|
||||
return json.dumps({
|
||||
"authenticated": False,
|
||||
"message": "Not authenticated. Run 'hanzo login' in your terminal to authenticate.",
|
||||
"services": {
|
||||
"iam": False,
|
||||
"kms": _check_kms_env(),
|
||||
"paas": False,
|
||||
},
|
||||
"reason": info.get("reason"),
|
||||
"detail": info.get("detail"),
|
||||
"message": "Not authenticated. Run the `login` action to sign in.",
|
||||
"services": {"iam": False, "kms": _check_kms_env(), "paas": False},
|
||||
}, indent=2)
|
||||
|
||||
services = {
|
||||
"iam": True,
|
||||
"kms": _check_kms_env(),
|
||||
"paas": info.get("authenticated", False),
|
||||
}
|
||||
|
||||
return json.dumps({
|
||||
"authenticated": True,
|
||||
"source": info.get("source", "unknown"),
|
||||
"store": info.get("store"),
|
||||
"organization": info.get("organization"),
|
||||
"server_url": info.get("server_url"),
|
||||
"expires_at": info.get("expires_at"),
|
||||
"expired": info.get("expired", False),
|
||||
"services": services,
|
||||
# IAM is reachable and the token verified; the others are reported
|
||||
# from what we can actually check, never assumed from the IAM token.
|
||||
"services": {"iam": True, "kms": _check_kms_env(), "paas": False},
|
||||
}, indent=2)
|
||||
|
||||
async def _whoami(self, session: HanzoSession) -> str:
|
||||
if not session.is_authenticated():
|
||||
return json.dumps({"error": "Not authenticated. Run 'hanzo login' first."})
|
||||
"""Report the identity the ISSUER vouches for.
|
||||
|
||||
try:
|
||||
iam_client = session.get_iam_client()
|
||||
token = session.get_iam_token()
|
||||
if token:
|
||||
# Try to decode JWT claims (without verification for display)
|
||||
try:
|
||||
import jwt
|
||||
|
||||
claims = jwt.decode(token, options={"verify_signature": False})
|
||||
return json.dumps({
|
||||
"sub": claims.get("sub"),
|
||||
"name": claims.get("name"),
|
||||
"email": claims.get("email"),
|
||||
"organization": claims.get("owner"),
|
||||
"iss": claims.get("iss"),
|
||||
}, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to stored token info
|
||||
info = session.get_token_info()
|
||||
return json.dumps({
|
||||
"organization": info.get("organization"),
|
||||
"server_url": info.get("server_url"),
|
||||
"source": info.get("source"),
|
||||
}, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"Failed to get user info: {e}"})
|
||||
Claims come from `verify()`, so they are only ever shown once the
|
||||
signature checked out. The previous version decoded the token with
|
||||
verify_signature=False and printed whatever it said — an attacker-
|
||||
chosen identity, rendered as fact.
|
||||
"""
|
||||
result = session.verify()
|
||||
if not result.valid:
|
||||
return json.dumps(
|
||||
{"error": "Not authenticated.", "reason": result.reason, "detail": result.detail},
|
||||
indent=2,
|
||||
)
|
||||
claims = result.claims
|
||||
return json.dumps({
|
||||
"sub": claims.get("sub"),
|
||||
"name": claims.get("name"),
|
||||
"email": claims.get("email"),
|
||||
"organization": claims.get("owner") or claims.get("organization"),
|
||||
"iss": claims.get("iss"),
|
||||
"aud": claims.get("aud"),
|
||||
"exp": claims.get("exp"),
|
||||
}, indent=2)
|
||||
|
||||
async def _logout(self, session: HanzoSession) -> str:
|
||||
session.logout()
|
||||
@@ -134,8 +167,10 @@ class LoginTool(BaseTool):
|
||||
return json.dumps({"message": "Logged out. Cleared stored credentials."})
|
||||
|
||||
async def _refresh(self, session: HanzoSession) -> str:
|
||||
if not session.is_authenticated():
|
||||
return json.dumps({"error": "Not authenticated. Run 'hanzo login' first."})
|
||||
# Refresh is exactly what an EXPIRED token needs, so this gates on
|
||||
# holding a credential, not on that credential still being valid.
|
||||
if not session.has_credential():
|
||||
return json.dumps({"error": "No stored credential. Run the `login` action first."})
|
||||
|
||||
if session.refresh_token():
|
||||
return json.dumps({"message": "Token refreshed successfully."})
|
||||
@@ -154,13 +189,26 @@ class LoginTool(BaseTool):
|
||||
action: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Action: status (check auth state), whoami (current user), logout, refresh",
|
||||
description=(
|
||||
"Action: login (browser sign-in), status (verified auth state),"
|
||||
" whoami (current user), logout, refresh"
|
||||
),
|
||||
default="status",
|
||||
),
|
||||
] = "status",
|
||||
timeout: Annotated[
|
||||
float,
|
||||
Field(description="login only: seconds to wait for the browser callback", default=300.0),
|
||||
] = 300.0,
|
||||
open_browser: Annotated[
|
||||
bool,
|
||||
Field(description="login only: open the URL automatically", default=True),
|
||||
] = True,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
return await tool_instance.call(ctx, action=action)
|
||||
return await tool_instance.call(
|
||||
ctx, action=action, timeout=timeout, open_browser=open_browser
|
||||
)
|
||||
|
||||
|
||||
def _check_kms_env() -> bool:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Hanzo authentication session — shared auth bridge for MCP platform tools.
|
||||
|
||||
Loads IAM tokens from disk or environment, auto-refreshes expired tokens,
|
||||
and provides authenticated service clients (KMS, PaaS, IAM).
|
||||
Resolves a credential, VERIFIES it, and hands out authenticated service
|
||||
clients (KMS, PaaS, IAM).
|
||||
|
||||
Token resolution order:
|
||||
Credential resolution order:
|
||||
1. HANZO_AUTH_TOKEN env var (explicit override)
|
||||
2. HANZO_API_KEY env var (API key auth)
|
||||
3. ~/.hanzo/auth/token.json (from `hanzo login`)
|
||||
2. HANZO_API_KEY env var (opaque API key)
|
||||
3. The token store (OS keyring, else ~/.hanzo/auth/token.json at 0600)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,20 +18,30 @@ import logging
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
from hanzo_iam import store
|
||||
from hanzo_iam.oauth import DEFAULT_CLIENT_ID, DEFAULT_IAM_URL, DEFAULT_ORG
|
||||
from hanzo_iam.models import OIDC_JWKS_PATH, OIDC_USERINFO_PATH
|
||||
from hanzo_iam.tokens import (
|
||||
BAD_SIGNATURE,
|
||||
JWKS_UNREACHABLE,
|
||||
NO_CREDENTIAL,
|
||||
OK,
|
||||
OPAQUE,
|
||||
Verification,
|
||||
)
|
||||
from hanzo_iam.tokens import verify as verify_jwt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOKEN_DIR = Path.home() / ".hanzo" / "auth"
|
||||
TOKEN_FILE = TOKEN_DIR / "token.json"
|
||||
|
||||
# IAM defaults (same as hanzo-cli)
|
||||
DEFAULT_IAM_URL = "https://hanzo.id"
|
||||
DEFAULT_ORG = "hanzo"
|
||||
DEFAULT_APP = "hanzo-app"
|
||||
DEFAULT_CLIENT_ID = "hanzo-app"
|
||||
|
||||
# Kept for callers that report where credentials live. The store owns writing.
|
||||
TOKEN_DIR = store.TOKEN_DIR
|
||||
TOKEN_FILE = store.TOKEN_FILE
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
"""Read env var (IAM_*)."""
|
||||
"""Read an IAM_* env var, normalising absence to the empty string."""
|
||||
return os.getenv(name) or ""
|
||||
|
||||
|
||||
@@ -62,19 +72,12 @@ class HanzoSession:
|
||||
# -- Token loading -------------------------------------------------------
|
||||
|
||||
def _load_token_from_disk(self) -> dict[str, Any] | None:
|
||||
"""Load stored token from ~/.hanzo/auth/token.json."""
|
||||
if not TOKEN_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(TOKEN_FILE.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
"""Load stored token from the credential store."""
|
||||
return store.load()
|
||||
|
||||
def _save_token(self, 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)
|
||||
"""Persist token data. The store picks keyring or an atomic 0600 file."""
|
||||
store.save(data)
|
||||
|
||||
def load_token(self) -> dict[str, Any] | None:
|
||||
"""Load token using the resolution chain.
|
||||
@@ -105,7 +108,7 @@ class HanzoSession:
|
||||
# 3. Stored token from `hanzo login`
|
||||
token_data = self._load_token_from_disk()
|
||||
if token_data and token_data.get("access_token"):
|
||||
token_data["source"] = "disk:~/.hanzo/auth/token.json"
|
||||
token_data["source"] = f"store:{store.backend()}"
|
||||
self._token_data = token_data
|
||||
return self._token_data
|
||||
|
||||
@@ -113,10 +116,63 @@ class HanzoSession:
|
||||
|
||||
# -- Token state ---------------------------------------------------------
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if we have a valid token."""
|
||||
def has_credential(self) -> bool:
|
||||
"""Report whether ANY credential is present. Says nothing about validity."""
|
||||
return self.load_token() is not None
|
||||
|
||||
def verify(self) -> Verification:
|
||||
"""Judge the held credential against the issuer's published keys.
|
||||
|
||||
This is the real check. `is_authenticated()` used to be
|
||||
`load_token() is not None`, which returned True for the literal string
|
||||
"fake.not.a.real.jwt" and made every downstream authorization decision
|
||||
in this package meaningless.
|
||||
|
||||
Fails CLOSED: if the JWKS cannot be fetched we do not know the token is
|
||||
good, so we do not claim it is. The reason code distinguishes that from
|
||||
an actually-bad token.
|
||||
"""
|
||||
token_data = self.load_token()
|
||||
if not token_data:
|
||||
return Verification(False, NO_CREDENTIAL, "no credential found")
|
||||
|
||||
token = token_data.get("access_token") or ""
|
||||
server_url = (token_data.get("server_url") or DEFAULT_IAM_URL).rstrip("/")
|
||||
result = verify_jwt(
|
||||
token,
|
||||
jwks_uri=f"{server_url}{OIDC_JWKS_PATH}",
|
||||
issuer=server_url,
|
||||
)
|
||||
if result.reason == OPAQUE:
|
||||
# An API key is a real credential that simply cannot be judged
|
||||
# offline. Ask the issuer instead of guessing.
|
||||
return self._verify_opaque(token, server_url)
|
||||
return result
|
||||
|
||||
def _verify_opaque(self, token: str, server_url: str) -> Verification:
|
||||
"""Confirm an opaque credential by calling userinfo — the authority."""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{server_url}{OIDC_USERINFO_PATH}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10.0,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
return Verification(False, JWKS_UNREACHABLE, f"cannot reach {server_url}: {e}")
|
||||
if resp.status_code == 200 and "json" in resp.headers.get("content-type", ""):
|
||||
return Verification(True, OK, claims=resp.json())
|
||||
return Verification(
|
||||
False,
|
||||
BAD_SIGNATURE,
|
||||
f"issuer rejected the credential ({resp.status_code})",
|
||||
)
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""True only when the held credential actually verifies."""
|
||||
return self.verify().valid
|
||||
|
||||
def get_iam_token(self) -> str | None:
|
||||
"""Get the current IAM access token."""
|
||||
token_data = self.load_token()
|
||||
@@ -125,23 +181,37 @@ class HanzoSession:
|
||||
return None
|
||||
|
||||
def get_token_info(self) -> dict[str, Any]:
|
||||
"""Get info about the current auth state."""
|
||||
"""Describe the current auth state. `authenticated` reflects VERIFICATION.
|
||||
|
||||
It used to reflect credential presence, so `auth status` cheerfully
|
||||
reported a garbage token as a working login.
|
||||
"""
|
||||
token_data = self.load_token()
|
||||
if not token_data:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": False, "reason": NO_CREDENTIAL}
|
||||
|
||||
result = self.verify()
|
||||
info: dict[str, Any] = {
|
||||
"authenticated": True,
|
||||
"authenticated": result.valid,
|
||||
"reason": result.reason,
|
||||
"source": token_data.get("source", "unknown"),
|
||||
"store": store.backend(),
|
||||
}
|
||||
if not result.valid:
|
||||
info["detail"] = result.detail
|
||||
|
||||
# Check expiry
|
||||
login_time = token_data.get("login_time", 0)
|
||||
expires_in = token_data.get("expires_in", 0)
|
||||
if login_time and expires_in:
|
||||
expires_at = login_time + expires_in
|
||||
info["expires_at"] = expires_at
|
||||
info["expired"] = time.time() > expires_at
|
||||
# Expiry, from the token's own exp when we could read it, else the
|
||||
# locally recorded issue time.
|
||||
exp = result.claims.get("exp")
|
||||
if exp:
|
||||
info["expires_at"] = int(exp)
|
||||
info["expired"] = time.time() > float(exp)
|
||||
else:
|
||||
login_time = token_data.get("login_time", 0)
|
||||
expires_in = token_data.get("expires_in", 0)
|
||||
if login_time and expires_in:
|
||||
info["expires_at"] = login_time + expires_in
|
||||
info["expired"] = time.time() > info["expires_at"]
|
||||
|
||||
# Add org/app info if available
|
||||
if token_data.get("organization"):
|
||||
@@ -190,7 +260,7 @@ class HanzoSession:
|
||||
|
||||
self._save_token(new_data)
|
||||
self._token_data = new_data
|
||||
self._token_data["source"] = "disk:~/.hanzo/auth/token.json"
|
||||
self._token_data["source"] = f"store:{store.backend()}"
|
||||
logger.info("Token refreshed successfully")
|
||||
return True
|
||||
|
||||
@@ -247,7 +317,7 @@ class HanzoSession:
|
||||
from hanzo_kms import KMSClient
|
||||
|
||||
# KMSClient() reads HANZO_KMS_URL / _ORG / _CLIENT_ID / _CLIENT_SECRET
|
||||
# / _TOKEN itself — see hanzo_kms.settings_from_env.
|
||||
# / _TOKEN itself -- see hanzo_kms.settings_from_env.
|
||||
self._kms_client = KMSClient()
|
||||
return self._kms_client
|
||||
|
||||
@@ -279,16 +349,39 @@ class HanzoSession:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Exchange IAM token for PaaS session
|
||||
# Exchange IAM token for PaaS session.
|
||||
#
|
||||
# MEASURED, not assumed: platform.hanzo.ai answers POST /v1/auth/login
|
||||
# with a bodyless 404, while /v1/org and /v1/user answer 401 — so the
|
||||
# PaaS API is up and gated, but the token-exchange route in front of it
|
||||
# is not deployed. The paas repo does mount it (platform/server.js:62 →
|
||||
# routes/auth.js) and it expects {gitUser:{provider,providerUserId,...}},
|
||||
# not the {provider,accessToken} this client used to send. Two faults,
|
||||
# and the deployment one cannot be fixed from here.
|
||||
#
|
||||
# So: fail with the actual diagnosis. Guessing a payload against a route
|
||||
# that 404s would just move the confusion downstream.
|
||||
with httpx.Client(base_url=base_url, timeout=30.0) as tmp:
|
||||
resp = tmp.post(
|
||||
"/v1/auth/login",
|
||||
json={"provider": "hanzo", "accessToken": iam_token},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise RuntimeError(
|
||||
f"PaaS token exchange is not deployed: POST {base_url}/v1/auth/login"
|
||||
" returned 404 while /v1/org returns 401. The route exists in the"
|
||||
" paas repo (platform/server.js -> routes/auth.js) but is not"
|
||||
" reachable at this edge. No client-side workaround exists."
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
raise RuntimeError("IAM token rejected by PaaS. Try 'hanzo login' again.")
|
||||
raise RuntimeError("IAM token rejected by PaaS. Sign in again.")
|
||||
resp.raise_for_status()
|
||||
if "json" not in resp.headers.get("content-type", ""):
|
||||
raise RuntimeError(
|
||||
f"PaaS login returned {resp.headers.get('content-type')} instead of"
|
||||
" JSON — the request reached a web page, not the API."
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
at = data.get("at", "")
|
||||
@@ -296,12 +389,13 @@ class HanzoSession:
|
||||
if not at:
|
||||
raise RuntimeError("PaaS login succeeded but no session token returned.")
|
||||
|
||||
# Cache session
|
||||
session_dir = Path.home() / ".hanzo" / "paas"
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
session_file = session_dir / "session.json"
|
||||
session_file.write_text(json.dumps({"at": at, "rt": rt, "login_time": int(time.time())}, indent=2))
|
||||
session_file.chmod(0o600)
|
||||
# Cache session under the same 0600-atomic discipline as the IAM token —
|
||||
# a PaaS session cookie is a bearer credential too.
|
||||
session_file = Path.home() / ".hanzo" / "paas" / "session.json"
|
||||
store._write_private(
|
||||
session_file,
|
||||
json.dumps({"at": at, "rt": rt, "login_time": int(time.time())}, indent=2),
|
||||
)
|
||||
|
||||
return _PaaSClientWrapper(base_url, at, rt)
|
||||
|
||||
@@ -319,11 +413,35 @@ class HanzoSession:
|
||||
|
||||
# -- Logout --------------------------------------------------------------
|
||||
|
||||
def login(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Run the interactive login, persist the result, and return its info.
|
||||
|
||||
Verification happens BEFORE the token is stored: a token we cannot
|
||||
check is not one we should keep and later present as proof of identity.
|
||||
"""
|
||||
from hanzo_iam import oauth
|
||||
|
||||
token_data = oauth.login(**kwargs)
|
||||
server_url = token_data["server_url"]
|
||||
result = verify_jwt(
|
||||
token_data["access_token"],
|
||||
jwks_uri=f"{server_url}{OIDC_JWKS_PATH}",
|
||||
issuer=server_url,
|
||||
)
|
||||
if not result.valid:
|
||||
raise RuntimeError(
|
||||
f"IAM issued a token this client cannot verify ({result.reason}:"
|
||||
f" {result.detail}). Refusing to store it."
|
||||
)
|
||||
self._save_token(token_data)
|
||||
self.close()
|
||||
self._token_data = None
|
||||
return {"claims": result.claims, "store": store.backend()}
|
||||
|
||||
@staticmethod
|
||||
def logout() -> None:
|
||||
"""Clear stored credentials."""
|
||||
if TOKEN_FILE.exists():
|
||||
TOKEN_FILE.unlink()
|
||||
"""Clear stored credentials from every backend."""
|
||||
store.clear()
|
||||
session_file = Path.home() / ".hanzo" / "paas" / "session.json"
|
||||
if session_file.exists():
|
||||
session_file.unlink()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzo-tools-auth"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "Authentication bridge for Hanzo MCP platform tools — session management, token refresh, service client factory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -17,7 +17,7 @@ classifiers = [
|
||||
|
||||
dependencies = [
|
||||
"hanzo-tools-core>=0.1.0",
|
||||
"hanzo-iam>=1.30.0",
|
||||
"hanzo-iam>=1.30.2",
|
||||
"httpx>=0.27.0",
|
||||
"pyjwt>=2.8.0",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Tests for HanzoSession's authentication state.
|
||||
|
||||
`test_fake_token_is_not_authenticated` is THE regression. Against the shipped
|
||||
implementation —
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
return self.load_token() is not None
|
||||
|
||||
— it FAILS: a stored token of "fake.not.a.real.jwt" made it return True, and
|
||||
every tool that gated on it believed the caller was signed in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from hanzo_iam import store
|
||||
from hanzo_iam import tokens
|
||||
from hanzo_tools.auth.session import HanzoSession
|
||||
|
||||
ISSUER = "https://hanzo.id"
|
||||
FAKE = "fake.not.a.real.jwt"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def keypair():
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
def _jwks(key, kid="test-key"):
|
||||
data = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key()))
|
||||
data.update({"kid": kid, "use": "sig", "alg": "RS256"})
|
||||
return {"keys": [data]}
|
||||
|
||||
|
||||
def _token(key, **overrides):
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"sub": "hanzo/z",
|
||||
"aud": "hanzo-app",
|
||||
"exp": int(time.time()) + 3600,
|
||||
"iat": int(time.time()),
|
||||
}
|
||||
claims.update(overrides)
|
||||
return jwt.encode(claims, key, algorithm="RS256", headers={"kid": "test-key"})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_session(tmp_path, monkeypatch):
|
||||
"""Isolate the store, clear env credentials, reset the singleton."""
|
||||
monkeypatch.setattr(store, "TOKEN_DIR", tmp_path / "auth")
|
||||
monkeypatch.setattr(store, "TOKEN_FILE", tmp_path / "auth" / "token.json")
|
||||
monkeypatch.setattr(store, "_keyring", lambda: None)
|
||||
monkeypatch.delenv("HANZO_AUTH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("HANZO_API_KEY", raising=False)
|
||||
tokens.reset_jwks_cache()
|
||||
HanzoSession.reset()
|
||||
yield
|
||||
HanzoSession.reset()
|
||||
tokens.reset_jwks_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def serve_jwks(monkeypatch):
|
||||
def _serve(document):
|
||||
monkeypatch.setattr(jwt.PyJWKClient, "fetch_data", lambda self: document)
|
||||
|
||||
return _serve
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_network(monkeypatch):
|
||||
"""Any HTTP call is a test bug unless the test opted into one."""
|
||||
import httpx
|
||||
|
||||
def forbidden(*a, **kw):
|
||||
raise AssertionError("unexpected network call")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", forbidden)
|
||||
|
||||
|
||||
def _store(token: str, **extra):
|
||||
store.save({"access_token": token, "server_url": ISSUER, **extra})
|
||||
|
||||
|
||||
# --- The defect -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_fake_token_is_not_authenticated(monkeypatch):
|
||||
"""A stored token of 'fake.not.a.real.jwt' must not authenticate anybody.
|
||||
|
||||
It is not a JWT, so it is treated as an opaque credential and put to the
|
||||
issuer, which rejects it. Under the old presence check this returned True.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
class Rejected:
|
||||
status_code = 401
|
||||
headers = {"content-type": "application/json"}
|
||||
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: Rejected())
|
||||
_store(FAKE)
|
||||
|
||||
session = HanzoSession.get()
|
||||
assert session.has_credential() is True # the string IS there
|
||||
assert session.is_authenticated() is False # but it proves nothing
|
||||
assert session.get_token_info()["authenticated"] is False
|
||||
|
||||
|
||||
def test_fake_token_in_env_is_not_authenticated(monkeypatch):
|
||||
"""Same defect via HANZO_AUTH_TOKEN, which bypasses the store entirely."""
|
||||
import httpx
|
||||
|
||||
class Rejected:
|
||||
status_code = 401
|
||||
headers = {"content-type": "application/json"}
|
||||
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: Rejected())
|
||||
monkeypatch.setenv("HANZO_AUTH_TOKEN", FAKE)
|
||||
|
||||
assert HanzoSession.get().is_authenticated() is False
|
||||
|
||||
|
||||
def test_presence_and_validity_are_different_questions(serve_jwks, keypair, no_network):
|
||||
"""The two used to be one method. Keeping them apart is the fix."""
|
||||
serve_jwks(_jwks(keypair))
|
||||
_store(_token(keypair, exp=int(time.time()) - 3600))
|
||||
session = HanzoSession.get()
|
||||
assert session.has_credential() is True
|
||||
assert session.is_authenticated() is False
|
||||
assert session.verify().reason == tokens.EXPIRED
|
||||
|
||||
|
||||
# --- Genuine credentials --------------------------------------------------
|
||||
|
||||
|
||||
def test_real_token_authenticates(serve_jwks, keypair, no_network):
|
||||
serve_jwks(_jwks(keypair))
|
||||
_store(_token(keypair))
|
||||
session = HanzoSession.get()
|
||||
assert session.is_authenticated() is True
|
||||
info = session.get_token_info()
|
||||
assert info["authenticated"] is True
|
||||
assert info["expired"] is False
|
||||
assert session.verify().claims["sub"] == "hanzo/z"
|
||||
|
||||
|
||||
def test_no_credential_at_all(no_network):
|
||||
session = HanzoSession.get()
|
||||
assert session.has_credential() is False
|
||||
assert session.is_authenticated() is False
|
||||
assert session.get_token_info() == {
|
||||
"authenticated": False,
|
||||
"reason": tokens.NO_CREDENTIAL,
|
||||
}
|
||||
|
||||
|
||||
# --- Forgeries ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_token_from_another_issuer_is_rejected(serve_jwks, keypair, no_network):
|
||||
"""Correctly signed, wrong issuer: still not a Hanzo session."""
|
||||
serve_jwks(_jwks(keypair))
|
||||
_store(_token(keypair, iss="https://evil.example"))
|
||||
assert HanzoSession.get().verify().reason == tokens.WRONG_ISSUER
|
||||
|
||||
|
||||
def test_self_signed_token_is_rejected(serve_jwks, keypair, no_network):
|
||||
other = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
serve_jwks(_jwks(keypair))
|
||||
_store(_token(other))
|
||||
assert HanzoSession.get().is_authenticated() is False
|
||||
|
||||
|
||||
def test_expiry_comes_from_the_token_not_from_local_bookkeeping(
|
||||
serve_jwks, keypair, no_network
|
||||
):
|
||||
"""A caller could otherwise claim a fresh session by editing login_time in
|
||||
the stored JSON. `exp` is signed; login_time is not."""
|
||||
serve_jwks(_jwks(keypair))
|
||||
exp = int(time.time()) + 60
|
||||
store.save(
|
||||
{
|
||||
"access_token": _token(keypair, exp=exp),
|
||||
"server_url": ISSUER,
|
||||
"login_time": int(time.time()) + 10**6,
|
||||
"expires_in": 10**6,
|
||||
}
|
||||
)
|
||||
assert HanzoSession.get().get_token_info()["expires_at"] == exp
|
||||
|
||||
|
||||
# --- Fail closed ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_offline_does_not_grant_access(monkeypatch, keypair):
|
||||
"""No JWKS, no verification, no session — an unreachable IdP must not be a
|
||||
free pass."""
|
||||
|
||||
def down(self):
|
||||
raise jwt.exceptions.PyJWKClientConnectionError("network down")
|
||||
|
||||
monkeypatch.setattr(jwt.PyJWKClient, "fetch_data", down)
|
||||
_store(_token(keypair))
|
||||
session = HanzoSession.get()
|
||||
assert session.is_authenticated() is False
|
||||
assert session.verify().reason == tokens.JWKS_UNREACHABLE
|
||||
|
||||
|
||||
def test_jwks_path_carries_the_v1_iam_prefix(serve_jwks, keypair, monkeypatch):
|
||||
"""The unprefixed /.well-known/jwks answers 200 text/html on hanzo.id, so
|
||||
verification would silently have nothing to check against."""
|
||||
seen: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
jwt.PyJWKClient,
|
||||
"fetch_data",
|
||||
lambda self: (seen.append(self.uri), _jwks(keypair))[1],
|
||||
)
|
||||
_store(_token(keypair))
|
||||
HanzoSession.get().verify()
|
||||
assert seen == [f"{ISSUER}/v1/iam/.well-known/jwks"]
|
||||
|
||||
|
||||
# --- Storage --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_session_persists_tokens_privately(serve_jwks, keypair, no_network):
|
||||
import stat
|
||||
|
||||
serve_jwks(_jwks(keypair))
|
||||
HanzoSession.get()._save_token({"access_token": _token(keypair), "server_url": ISSUER})
|
||||
assert stat.S_IMODE(store.TOKEN_FILE.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_logout_clears_the_credential(serve_jwks, keypair, no_network):
|
||||
serve_jwks(_jwks(keypair))
|
||||
_store(_token(keypair))
|
||||
session = HanzoSession.get()
|
||||
assert session.is_authenticated() is True
|
||||
session.logout()
|
||||
HanzoSession.reset()
|
||||
assert HanzoSession.get().is_authenticated() is False
|
||||
|
||||
|
||||
def test_login_refuses_to_store_a_token_it_cannot_verify(monkeypatch, keypair):
|
||||
"""If IAM hands back something unverifiable, that is a bug to surface, not
|
||||
a credential to keep and present as proof of identity later."""
|
||||
from hanzo_iam import oauth
|
||||
|
||||
monkeypatch.setattr(
|
||||
oauth,
|
||||
"login",
|
||||
lambda **kw: {"access_token": FAKE, "server_url": ISSUER, "client_id": "hanzo-app"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
jwt.PyJWKClient, "fetch_data", lambda self: _jwks(keypair)
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="cannot verify"):
|
||||
HanzoSession.get().login()
|
||||
assert store.load() is None
|
||||
@@ -23,7 +23,22 @@ from hanzo_tools.core.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEAM_BASE_URL = os.getenv("HANZO_TEAM_URL", "https://api.hanzo.ai/team")
|
||||
# There is no Hanzo Team API. MEASURED against the live edge:
|
||||
# GET https://api.hanzo.ai/team/... -> 200 text/html (the marketing SPA)
|
||||
# GET https://api.hanzo.ai/v1/team/... -> 404
|
||||
# GET https://api.hanzo.ai/v1/workspaces -> 404
|
||||
# GET https://team.hanzo.ai/... -> 200 text/html (a different SPA)
|
||||
# The old default pointed at the first of those, so every call here sailed
|
||||
# through raise_for_status() on a 200 and then died inside .json() — a missing
|
||||
# service reported as a parse error.
|
||||
#
|
||||
# What DOES exist and is auth-gated is IAM's tenancy surface:
|
||||
# /v1/iam/get-organizations and /v1/iam/get-groups. Mapping "workspace" onto
|
||||
# org-or-group is a product decision, not something to guess at here.
|
||||
#
|
||||
# HANZO_TEAM_URL stays as the seam: set it and this tool works the day the
|
||||
# service ships. Unset, it refuses rather than inventing an answer.
|
||||
TEAM_BASE_URL = os.getenv("HANZO_TEAM_URL", "")
|
||||
|
||||
DESCRIPTION = """Hanzo Team — workspace and member management.
|
||||
|
||||
@@ -47,8 +62,16 @@ def _get_session():
|
||||
|
||||
|
||||
def _team_url(path: str) -> str:
|
||||
"""Build full Team API URL."""
|
||||
return f"{TEAM_BASE_URL}/{path.lstrip('/')}"
|
||||
"""Build the full Team API URL, or refuse because there is no Team API."""
|
||||
if not TEAM_BASE_URL:
|
||||
raise RuntimeError(
|
||||
"No Hanzo Team API exists. api.hanzo.ai/team serves the marketing"
|
||||
" site (200 text/html) and /v1/team 404s. For org and group"
|
||||
" membership use the `iam` tool (/v1/iam/get-organizations,"
|
||||
" /v1/iam/get-groups). Set HANZO_TEAM_URL to point this tool at a"
|
||||
" real Team service once one is deployed."
|
||||
)
|
||||
return f"{TEAM_BASE_URL.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _auth_headers(token: str) -> dict[str, str]:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tools-team"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "Hanzo MCP tool for Team — workspaces, members, and invitations"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user