fix(cli): auth login was broken end-to-end — Cloudflare 1010, no PKCE, legacy paths
Three defects in `hanzo auth login`, found while auditing HIP-0111 compliance.
1. LOGIN DID NOT WORK AT ALL. urllib defaults its User-Agent to
`Python-urllib/3.x`, which Cloudflare refuses in front of hanzo.id with
`error code: 1010` — a 403 that never reaches IAM. The browser leg looked
fine, the user signed in, and THEN the token exchange died, so the failure
landed after the user had already authenticated. Measured on the live host:
POST /v1/iam/oauth/token UA=Python-urllib/3.11 -> 403 (CF 1010)
POST /v1/iam/oauth/token UA=hanzo-cli/python -> 400 (reaches IAM)
POST /v1/iam/oauth/device UA=Python-urllib/3.11 -> 403 (CF 1010)
POST /v1/iam/oauth/device UA=hanzo-cli/python -> 400 (reaches IAM)
Every outbound request now identifies itself. The device flow was dead the
same way.
2. NO PKCE. The flow had zero code_challenge/code_verifier — `state` only,
which is CSRF protection, not interception protection. HIP-0111 Security
Considerations makes PKCE S256 mandatory, and RFC 8252 §8.1 requires it for
native apps specifically: this redirects to a fixed loopback port (1456) that
any local process can bind or race, so an intercepted authorization code was
directly redeemable. Now S256, with the verifier bound into the exchange.
hanzo.id advertises `code_challenge_methods_supported: ["S256"]`.
3. LEGACY PATHS (HIP-0111 §4.4). It called `/oauth/authorize`, `/oauth/token`
and `/api/device/code` — the `/oauth/*` and `/api/*` spellings the standard
retired, the latter breaking the absolute "no /api/" rule. Replaced with the
canonical paths hanzo.id actually advertises in its discovery document, held
in ONE constant block so no call site spells a path itself (§4.3):
/v1/iam/oauth/authorize /v1/iam/oauth/token /v1/iam/oauth/device
`/api/device/code` was not merely non-canonical — it answers 401; the real
device endpoint is `/v1/iam/oauth/device`, per discovery.
Also drops a redundant function-local `import base64` now that the module
imports it.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import threading
|
||||
import webbrowser
|
||||
@@ -25,6 +27,23 @@ CALLBACK_PORT = 1456
|
||||
CALLBACK_PATH = "/callback"
|
||||
CALLBACK_URI = f"http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}"
|
||||
|
||||
# Canonical IAM OIDC paths (HIP-0111 §1), in ONE place so no call site spells a
|
||||
# path itself. These match what hanzo.id advertises in
|
||||
# /.well-known/openid-configuration. The legacy `/oauth/*` and `/api/*` spellings
|
||||
# this file used are forbidden by §4.4 — IAM answers any unregistered path with a
|
||||
# 200 text/html SPA catch-all, so a wrong path is silent breakage, not a 404.
|
||||
OIDC_AUTHORIZE = "/v1/iam/oauth/authorize"
|
||||
OIDC_TOKEN = "/v1/iam/oauth/token"
|
||||
OIDC_DEVICE = "/v1/iam/oauth/device"
|
||||
|
||||
# Every request MUST identify itself. urllib defaults to `Python-urllib/3.x`,
|
||||
# which Cloudflare refuses in front of hanzo.id with `error code: 1010` — a 403
|
||||
# that never reaches IAM. The browser leg looked fine and then the token
|
||||
# exchange died, so login failed AFTER the user had already signed in. Verified:
|
||||
# POST /v1/iam/oauth/token answers 403 with the urllib UA and 400 with any real
|
||||
# one. Applies to the device legs identically.
|
||||
USER_AGENT = "hanzo-cli/python"
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manage Hanzo authentication."""
|
||||
@@ -142,8 +161,6 @@ def _get_iam_url(auth_mgr: AuthManager) -> str:
|
||||
|
||||
def _decode_jwt_claims(token: str) -> dict:
|
||||
"""Decode JWT payload without verification (for extracting email/name)."""
|
||||
import base64
|
||||
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2:
|
||||
@@ -220,6 +237,18 @@ def _login_browser_oauth(auth_mgr: AuthManager):
|
||||
server_thread.start()
|
||||
server_ready.wait()
|
||||
|
||||
# PKCE S256 (RFC 7636). Mandatory per HIP-0111 Security Considerations, and
|
||||
# RFC 8252 §8.1 requires it for native apps specifically: this flow redirects
|
||||
# to a loopback port any local process can also bind or race, so without a
|
||||
# code_verifier an intercepted authorization code is directly redeemable.
|
||||
# `state` alone is CSRF protection, not interception protection.
|
||||
code_verifier = secrets.token_urlsafe(64)[:128]
|
||||
code_challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
# Build OAuth authorize URL
|
||||
params = {
|
||||
"client_id": IAM_CLIENT_ID,
|
||||
@@ -227,8 +256,10 @@ def _login_browser_oauth(auth_mgr: AuthManager):
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email",
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
authorize_url = f"{iam_url}/oauth/authorize?{urlencode(params)}"
|
||||
authorize_url = f"{iam_url}{OIDC_AUTHORIZE}?{urlencode(params)}"
|
||||
|
||||
console.print("Your browser has been opened to visit:\n")
|
||||
console.print(f" {authorize_url}\n")
|
||||
@@ -248,20 +279,26 @@ def _login_browser_oauth(auth_mgr: AuthManager):
|
||||
return
|
||||
|
||||
# Exchange authorization code for tokens
|
||||
token_url = f"{iam_url}/oauth/token"
|
||||
token_url = f"{iam_url}{OIDC_TOKEN}"
|
||||
token_data = urlencode(
|
||||
{
|
||||
"client_id": IAM_CLIENT_ID,
|
||||
"code": auth_result["code"],
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": CALLBACK_URI,
|
||||
# Binds this exchange to the challenge sent on the authorize leg —
|
||||
# without it an intercepted code is redeemable by anyone.
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
).encode()
|
||||
|
||||
req = urllib.request.Request( # noqa: S310
|
||||
token_url,
|
||||
data=token_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
@@ -315,9 +352,9 @@ def _login_device_code(auth_mgr: AuthManager, headless: bool):
|
||||
).encode()
|
||||
|
||||
req = urllib.request.Request( # noqa: S310
|
||||
f"{iam_url}/api/device/code",
|
||||
f"{iam_url}{OIDC_DEVICE}",
|
||||
data=device_req_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
@@ -362,9 +399,9 @@ def _login_device_code(auth_mgr: AuthManager, headless: bool):
|
||||
).encode()
|
||||
|
||||
poll_req = urllib.request.Request( # noqa: S310
|
||||
f"{iam_url}/oauth/token",
|
||||
f"{iam_url}{OIDC_TOKEN}",
|
||||
data=poll_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user