mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user