hanzo-iam: one decoder, so a wrong path names itself

hanzo.id serves its sign-in SPA on every unmatched path, so a path IAM
does not serve answers 200 text/html. Forty-four call sites went straight
to .json() on the strength of the status code and died inside it with a
byte offset naming neither the path nor the reason. oauth was the one
module that asked, in a guard of its own.

routes.decode is that question, once, beside the unwrap that answers the
envelope one: the wire decision, then the envelope decision, each with a
name. A test walks the syntax tree and fails if any module decodes a body
on its own.

141 pass across the package and the two consumers that build on it.
This commit is contained in:
2026-08-23 12:57:04 -07:00
parent 28035b6721
commit 53cd11b0a5
5 changed files with 138 additions and 44 deletions
+24 -24
View File
@@ -142,7 +142,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(OIDC_DISCOVERY_PATH)
response.raise_for_status()
self._openid_config = response.json()
self._openid_config = routes.decode(response)
return self._openid_config
async def get_jwks(self) -> dict[str, Any]:
@@ -154,7 +154,7 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(OIDC_JWKS_PATH)
response.raise_for_status()
return response.json()
return routes.decode(response)
# =========================================================================
# Authorization Code Flow
@@ -240,7 +240,7 @@ class AsyncIAMClient:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
return TokenResponse.model_validate(routes.decode(response))
async def refresh_token(self, refresh_token: str) -> TokenResponse:
"""Refresh access token using refresh token.
@@ -265,7 +265,7 @@ class AsyncIAMClient:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
return TokenResponse.model_validate(routes.decode(response))
# =========================================================================
# Client Credentials Flow (M2M)
@@ -294,7 +294,7 @@ class AsyncIAMClient:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
return TokenResponse.model_validate(routes.decode(response))
# =========================================================================
# Token Validation
@@ -401,7 +401,7 @@ class AsyncIAMClient:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return response.json()
return routes.decode(response)
# =========================================================================
# User Info
@@ -422,7 +422,7 @@ class AsyncIAMClient:
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
return UserInfo.model_validate(response.json())
return UserInfo.model_validate(routes.decode(response))
# =========================================================================
# User Management (IAM Admin API)
@@ -448,7 +448,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user"))
@@ -472,7 +472,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get users"))
@@ -508,7 +508,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if isinstance(data, dict) and data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user count"))
@@ -557,7 +557,7 @@ class AsyncIAMClient:
json=user.model_dump(by_alias=True, exclude_none=True),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", f"Failed to {action}"))
@@ -581,7 +581,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get application"))
@@ -609,7 +609,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organizations"))
@@ -636,7 +636,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organization"))
@@ -687,7 +687,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if isinstance(data, dict) and data.get("status") == "error":
raise ValueError(data.get("msg", "Enforcement failed"))
@@ -731,7 +731,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if isinstance(data, dict) and data.get("status") == "error":
raise ValueError(data.get("msg", "Batch enforcement failed"))
@@ -763,7 +763,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get roles"))
@@ -797,7 +797,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get role"))
@@ -831,7 +831,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user roles"))
@@ -868,7 +868,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to add role for user"))
@@ -905,7 +905,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to remove role from user"))
@@ -948,7 +948,7 @@ class AsyncIAMClient:
json=payload,
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to set password"))
@@ -979,7 +979,7 @@ class AsyncIAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get applications"))
@@ -1003,7 +1003,7 @@ class AsyncIAMClient:
json=application.model_dump(by_alias=True, exclude_none=True),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to update application"))
+19 -19
View File
@@ -145,7 +145,7 @@ class IAMClient:
if self._openid_config is None:
response = self.http.get(OIDC_DISCOVERY_PATH)
response.raise_for_status()
self._openid_config = response.json()
self._openid_config = routes.decode(response)
return self._openid_config
def get_jwks(self) -> dict:
@@ -156,7 +156,7 @@ class IAMClient:
"""
response = self.http.get(OIDC_JWKS_PATH)
response.raise_for_status()
return response.json()
return routes.decode(response)
# =========================================================================
# Authorization Code Flow
@@ -239,7 +239,7 @@ class IAMClient:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
return TokenResponse.model_validate(routes.decode(response))
def refresh_token(self, refresh_token: str) -> TokenResponse:
"""Refresh access token using refresh token.
@@ -263,7 +263,7 @@ class IAMClient:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
return TokenResponse.model_validate(routes.decode(response))
# =========================================================================
# Client Credentials Flow (M2M)
@@ -291,7 +291,7 @@ class IAMClient:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return TokenResponse.model_validate(response.json())
return TokenResponse.model_validate(routes.decode(response))
# =========================================================================
# Token Validation
@@ -322,7 +322,7 @@ class IAMClient:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return response.json()
return routes.decode(response)
# =========================================================================
# User Info
@@ -342,7 +342,7 @@ class IAMClient:
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
return UserInfo.model_validate(response.json())
return UserInfo.model_validate(routes.decode(response))
# =========================================================================
# Admin Auth Helpers
@@ -381,7 +381,7 @@ class IAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get user"))
@@ -404,7 +404,7 @@ class IAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get users"))
@@ -428,7 +428,7 @@ class IAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get application"))
@@ -480,7 +480,7 @@ class IAMClient:
json=user.model_dump(by_alias=True, exclude_none=True),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", f"Failed to {action}"))
@@ -507,7 +507,7 @@ class IAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organizations"))
@@ -533,7 +533,7 @@ class IAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get organization"))
@@ -563,7 +563,7 @@ class IAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get providers"))
@@ -593,7 +593,7 @@ class IAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get roles"))
@@ -635,7 +635,7 @@ class IAMClient:
json=payload,
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to set password"))
@@ -665,7 +665,7 @@ class IAMClient:
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get applications"))
@@ -688,7 +688,7 @@ class IAMClient:
json=application.model_dump(by_alias=True, exclude_none=True),
)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to update application"))
@@ -719,7 +719,7 @@ class IAMClient:
response = self.http.post(f"{IAM_ROUTE_PREFIX}/login", json=payload)
response.raise_for_status()
data = response.json()
data = routes.decode(response)
if data.get("status") != "ok":
raise ValueError(data.get("msg", "Login failed"))
+2 -1
View File
@@ -34,6 +34,7 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from hanzo_iam import routes
from hanzo_iam.config import IAMConfig
from hanzo_iam.models import (
OIDC_USERINFO_PATH,
@@ -251,7 +252,7 @@ async def _fetch_user_info(token: str) -> UserInfo:
headers={"WWW-Authenticate": "Bearer"},
)
return UserInfo.model_validate(response.json())
return UserInfo.model_validate(routes.decode(response))
async def get_current_user(
+26
View File
@@ -29,6 +29,8 @@ surface only.
from __future__ import annotations
import httpx
from typing import Any
# --- native admin routes -----------------------------------------------------
@@ -60,6 +62,30 @@ LIST_KEY = {
}
class IAMError(Exception):
"""An IAM call did not produce a value."""
def decode(response: httpx.Response) -> Any:
"""The wire decision, before the envelope decision: is this JSON at all?
hanzo.id serves its sign-in SPA on every unmatched path, so a wrong path
answers 200 text/html. A decoder that trusts the status code then dies
inside .json() with a byte offset; this names the path instead.
"""
kind = response.headers.get("content-type", "")
if "json" not in kind:
raise IAMError(
f"{response.request.method} {response.request.url} answered"
f" {response.status_code} {kind or 'no content-type'} rather than JSON"
" — that is a path IAM does not serve, not a refusal"
)
try:
return response.json()
except ValueError as e:
raise IAMError(f"{response.request.url} returned unparseable JSON: {e}") from e
def unwrap(body: Any, list_key: str | None = None) -> Any:
"""Return the payload from either the native or the legacy envelope.
+67
View File
@@ -0,0 +1,67 @@
"""One wire decision: is the body JSON, and did IAM answer at all?
hanzo.id serves its sign-in SPA on every unmatched path, so a wrong path answers
200 text/html. A decoder that trusts the status code dies inside .json() with a
byte offset that names neither the path nor the reason.
"""
import ast
from pathlib import Path
import httpx
import pytest
from hanzo_iam import routes
def response(body: bytes, content_type: str, status: int = 200) -> httpx.Response:
request = httpx.Request("GET", "https://hanzo.id/v1/iam/nope")
return httpx.Response(status, content=body, headers={"content-type": content_type}, request=request)
def test_json_decodes():
assert routes.decode(response(b'{"users": []}', "application/json")) == {"users": []}
def test_the_sign_in_page_names_the_path():
with pytest.raises(routes.IAMError) as caught:
routes.decode(response(b"<!doctype html><title>Sign in</title>", "text/html"))
message = str(caught.value)
assert "/v1/iam/nope" in message
assert "text/html" in message
assert "not a refusal" in message
def test_a_body_with_no_content_type_is_not_guessed():
with pytest.raises(routes.IAMError):
routes.decode(response(b"{}", ""))
def test_unparseable_json_names_the_url():
with pytest.raises(routes.IAMError) as caught:
routes.decode(response(b"{not json", "application/json"))
assert "hanzo.id" in str(caught.value)
def test_nothing_decodes_a_body_on_its_own():
"""Every module asks routes.decode.
routes owns the decision; oauth carries the same guard for the login flow,
which runs before a client exists. A mention in a comment is not a call, so
this reads the syntax tree rather than the text.
"""
package = Path(__file__).resolve().parent.parent / "hanzo_iam"
loose = set()
for f in package.glob("*.py"):
if f.name in {"routes.py", "oauth.py"}:
continue
for node in ast.walk(ast.parse(f.read_text())):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "json"
and not node.args
):
loose.add(f.name)
assert not loose, loose