Files
zeekayandHanzo Dev d08a7d5599 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]>
2026-07-27 18:55:24 -07:00

355 lines
13 KiB
Python

"""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