Add verified browser login and human approval HTTP client

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-10 22:05:17 +02:00
parent 4e103f62a0
commit 0e48355b9f
16 changed files with 1365 additions and 39 deletions

View file

@ -0,0 +1,82 @@
"""Authenticated get-by-id and human entry transport; no policy or consume API.
The caller owes entitlement, durable presentation/disposition and audit custody
before invoking add_entry. This adapter is deliberately not a browser route.
"""
from __future__ import annotations
from datetime import datetime
import re
import time
from .approval_client import ApprovalEngineError, EntryResult, is_success
from .http_transport import JSONTransport, TransportError, fixed_origin
from .oidc import HumanSession
from .provenance import assert_human_control_dischargeable
class ApprovalHTTPClient:
def __init__(self, origin: str, session: HumanSession, *, transport=None, clock=time.time,
allow_internal_http=False):
self.origin = fixed_origin(origin, allow_internal_http=allow_internal_http)
assert_human_control_dischargeable(session.principal_type)
if session.tenant.value != "tenant:platform":
raise ValueError("platform approver required")
self.session = session
self.transport = transport or JSONTransport(allow_internal_http=allow_internal_http)
self.clock = clock
def _call(self, method: str, approval_id: str, suffix="") -> dict:
if not isinstance(approval_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", approval_id):
raise ValueError("invalid approval id")
if self.session.expires_at <= self.clock():
raise ApprovalEngineError(401, "session_expired")
try:
status, data = self.transport.request(method, self.origin + "/v1/approvals/" + approval_id + suffix,
headers={"Authorization": "Bearer " + self.session.access_token,
"Content-Type": "application/json"}, body=b"{}" if method == "POST" else None)
except TransportError:
# A failed POST transport may already have committed. Never retry it
# automatically or manufacture a disposition from this uncertainty.
raise ApprovalEngineError(502, "upstream_unavailable") from None
if status != 200:
reason = data.get("error")
known = {"unauthenticated", "forbidden", "not_found", "conflict",
"duplicate_approver", "unprocessable", "store_unavailable"}
raise ApprovalEngineError(status, reason if isinstance(reason, str) and reason in known else "upstream_refused")
binding = data.get("binding")
if (data.get("id") != approval_id or not isinstance(binding, dict)
or binding.get("human_control") is not True
or not isinstance(binding.get("digest"), str)
or not re.fullmatch(r"sha256:[0-9a-f]{64}", binding["digest"])
or data.get("status") not in {"requested", "approved", "consumed", "expired", "revoked", "superseded"}):
raise ApprovalEngineError(502, "invalid_approval_response")
return data
def get_approval(self, approval_id: str) -> dict:
return self._call("GET", approval_id)
def add_entry(self, approval_id: str) -> EntryResult:
self.get_approval(approval_id) # Require the declared human-control object before mutation.
duplicate = False
try:
data = self._call("POST", approval_id, "/entries")
except ApprovalEngineError as exc:
if not is_success(exc):
raise
duplicate = True
data = self.get_approval(approval_id)
entries = data.get("entries")
if not isinstance(entries, list) or any(not isinstance(e, dict) for e in entries):
raise ApprovalEngineError(502, "entry_correlation_missing")
matches = [e for e in entries if e.get("subject_id") == self.session.subject]
try:
if len(matches) != 1 or matches[0].get("principal_type") != "human":
raise ValueError()
at = matches[0]["approved_at"]
if not isinstance(at, str) or datetime.fromisoformat(at.replace("Z", "+00:00")).tzinfo is None:
raise ValueError()
except (KeyError, TypeError, ValueError):
raise ApprovalEngineError(502, "entry_correlation_missing") from None
return EntryResult(approval_id, self.session.subject, at, data["status"], duplicate)

View file

@ -0,0 +1,73 @@
"""Bounded JSON transport for fixed, deployment-owned HTTPS endpoints."""
from __future__ import annotations
import json
import ipaddress
from http.client import HTTPException
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
class TransportError(Exception):
"""Deliberately contains no response body, URL, token or request data."""
def fixed_origin(value: str, *, allow_internal_http=False) -> str:
parsed = urlsplit(value)
internal_http = False
if allow_internal_http and parsed.scheme == "http" and parsed.hostname:
try:
internal_http = ipaddress.ip_address(parsed.hostname).is_loopback
except ValueError:
internal_http = parsed.hostname.endswith((".svc", ".svc.cluster.local"))
if ((parsed.scheme != "https" and not internal_http) or not parsed.hostname or parsed.username
or parsed.password or parsed.path or parsed.query or parsed.fragment
or any(c.isspace() for c in value)):
raise ValueError("a fixed HTTPS origin without path or credentials is required")
return value
def https_origin(value: str) -> str:
return fixed_origin(value)
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class JSONTransport:
"""No redirects or ambient proxy credentials; five-second request timeout."""
def __init__(self, *, allow_internal_http=False) -> None:
self.allow_internal_http = allow_internal_http
self._opener = build_opener(ProxyHandler({}), _NoRedirect())
def request(self, method: str, url: str, *, headers=None, body=None) -> tuple[int, dict]:
parsed = urlsplit(url)
try:
fixed_origin(parsed.scheme + "://" + parsed.netloc,
allow_internal_http=self.allow_internal_http)
except ValueError:
raise TransportError("trusted transport required") from None
req = Request(url, method=method, data=body, headers=headers or {})
try:
try:
response = self._opener.open(req, timeout=5)
except HTTPError as exc:
response = exc
with response:
status = response.code
if 300 <= status < 400:
raise TransportError("upstream redirect refused")
raw = response.read(262145)
if len(raw) > 262144:
raise TransportError("upstream response too large")
result = json.loads(raw)
if not isinstance(result, dict):
raise TransportError("upstream object required")
return status, result
except (URLError, OSError, ValueError, HTTPException):
raise TransportError("upstream request failed") from None

187
informed_decision/oidc.py Normal file
View file

@ -0,0 +1,187 @@
"""KeyCape public-client authorization-code/PKCE login, never local identity.
The issuer and its fixed endpoints are trusted deployment configuration. The
human provenance mapping is specific to KeyCape's user-authenticated code flow
in internal/server/oidc/token.go, not a property inferred from client metadata.
"""
from __future__ import annotations
import base64
import hashlib
import secrets
import threading
import time
from dataclasses import dataclass, field
from urllib.parse import urlencode
import jwt
from .approval_client import REQUIRED_SCOPES
from .http_transport import JSONTransport, TransportError, https_origin
from .provenance import Claim, Route, assert_human_control_dischargeable
CLIENT_ID = "informed-decision-approver"
ORIGIN = "https://decisions.coulomb.social"
CALLBACK = ORIGIN + "/auth/callback"
class LoginError(Exception):
"""Safe, fixed errors: never include issuer response bodies or tokens."""
@dataclass(frozen=True)
class HumanSession:
subject: str
tenant: Claim
principal_type: Claim
assurance: dict = field(repr=False)
expires_at: float
access_token: str = field(repr=False)
csrf: str = field(default_factory=lambda: secrets.token_urlsafe(32), repr=False)
class KeyCapeLogin:
"""Single-process, bounded ephemeral sessions. Restart means reauthenticate.
No refresh token, approval state, memo, or evidence is stored here. Multiple
replicas need a separately designed shared session store before deployment.
"""
def __init__(self, issuer: str, *, transport=None, clock=time.time, capacity=1024):
self.issuer = https_origin(issuer)
self.transport = transport or JSONTransport()
self.clock = clock
self.capacity = capacity
self._lock = threading.RLock()
self._pending: dict[str, tuple] = {}
self._sessions: dict[str, HumanSession] = {}
self._keys: dict[str, object] = {}
self._keys_until = 0.0
def _prune(self):
now = self.clock()
self._pending = {k: v for k, v in self._pending.items() if v[0] > now}
self._sessions = {k: v for k, v in self._sessions.items() if v.expires_at > now}
def start(self) -> tuple[str, str]:
with self._lock:
self._prune()
if len(self._pending) >= self.capacity:
raise LoginError("login capacity reached")
state, browser, nonce = (secrets.token_urlsafe(32) for _ in range(3))
verifier = secrets.token_urlsafe(64)
self._pending[state] = (self.clock() + 300, browser, nonce, verifier)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
return self.issuer + "/authorize?" + urlencode({
"response_type": "code", "client_id": CLIENT_ID, "redirect_uri": CALLBACK,
"scope": " ".join(REQUIRED_SCOPES), "state": state, "nonce": nonce,
"code_challenge": challenge, "code_challenge_method": "S256",
}), browser
def _decode(self, token: str, audience: str) -> dict:
if not isinstance(token, str) or not token or len(token) > 32768:
raise LoginError("token verification failed")
try:
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if header.get("alg") != "RS256" or not isinstance(kid, str) or not kid:
raise LoginError("token verification failed")
with self._lock:
if self.clock() >= self._keys_until or kid not in self._keys:
status, data = self.transport.request("GET", self.issuer + "/jwks")
if status != 200 or not isinstance(data.get("keys"), list):
raise LoginError("token verification failed")
keys = {}
for raw in data["keys"]:
if (raw.get("kty") != "RSA" or raw.get("use", "sig") != "sig"
or raw.get("alg", "RS256") != "RS256"):
continue
key = jwt.PyJWK.from_dict(raw, algorithm="RS256")
if not key.key_id or key.key_id in keys:
raise LoginError("token verification failed")
keys[key.key_id] = key.key
self._keys, self._keys_until = keys, self.clock() + 300
key = self._keys[kid]
claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer,
audience=audience, leeway=30, options={
"require": ["iss", "aud", "sub", "exp", "iat"],
"strict_aud": True,
})
if (not isinstance(claims["sub"], str) or not claims["sub"]
or any(type(claims[k]) is not int for k in ("iat", "exp"))
or claims["exp"] <= self.clock() or claims["exp"] <= claims["iat"]):
raise LoginError("token verification failed")
return claims
except (jwt.PyJWTError, TransportError, KeyError, TypeError, ValueError, AttributeError):
raise LoginError("token verification failed") from None
def finish(self, state: str, browser: str, code: str) -> str:
with self._lock:
self._prune()
pending = self._pending.pop(state, None)
if not pending or not browser or not secrets.compare_digest(browser, pending[1]):
raise LoginError("login state invalid or expired")
if not code or len(code) > 4096:
raise LoginError("authorization code missing or invalid")
try:
status, tokens = self.transport.request("POST", self.issuer + "/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
body=urlencode({"grant_type": "authorization_code", "client_id": CLIENT_ID,
"redirect_uri": CALLBACK, "code_verifier": pending[3],
"code": code}).encode())
except TransportError:
raise LoginError("token exchange unavailable") from None
token_type = tokens.get("token_type")
if status != 200 or not isinstance(token_type, str) or token_type.lower() != "bearer":
raise LoginError("token exchange refused")
identity = self._decode(tokens.get("id_token"), CLIENT_ID)
access = self._decode(tokens.get("access_token"), "approval-engine")
if identity.get("nonce") != pending[2] or identity["sub"] != access["sub"]:
raise LoginError("login identity mismatch")
for name in ("tenant", "tenant_source", "principal_type", "assurance"):
if identity.get(name) != access.get(name):
raise LoginError("login identity mismatch")
scope = access.get("scope")
if not isinstance(scope, str) or set(scope.split()) != set(REQUIRED_SCOPES):
raise LoginError("unexpected token scopes")
if access.get("principal_type") != "human" or access.get("tenant") != "tenant:platform":
raise LoginError("human approver profile required")
if (not isinstance(access.get("roles"), list)
or any(not isinstance(x, str) for x in access["roles"])):
raise LoginError("human approver profile required")
assurance = access.get("assurance")
if (not isinstance(assurance, dict) or assurance.get("level") != "aal2"
or assurance.get("mfa") is not True or assurance.get("source") != "key-cape"
or assurance.get("methods") != ["pwd", "otp"]
or type(assurance.get("at")) is not int or assurance["at"] <= 0
or assurance["at"] > min(access["iat"], self.clock()) + 30):
raise LoginError("verified MFA facts required")
tenant_source = access.get("tenant_source")
if not isinstance(tenant_source, str):
raise LoginError("tenant provenance required")
tenant_route = {"directory": Route.DIRECTORY, "registration": Route.REGISTRATION,
"default": Route.INDETERMINATE}.get(tenant_source, Route.INDETERMINATE)
if tenant_route is Route.INDETERMINATE:
raise LoginError("tenant provenance required")
human = Claim("human", Route.AUTHENTICATION)
assert_human_control_dischargeable(human)
session = HumanSession(access["sub"], Claim(access["tenant"], tenant_route), human,
dict(assurance), min(identity["exp"], access["exp"], self.clock() + 900),
tokens["access_token"])
with self._lock:
self._prune()
if len(self._sessions) >= self.capacity:
raise LoginError("session capacity reached")
sid = secrets.token_urlsafe(32)
self._sessions[sid] = session
return sid
def session(self, sid: str) -> HumanSession | None:
with self._lock:
self._prune()
return self._sessions.get(sid)
def logout(self, sid: str) -> None:
with self._lock:
self._sessions.pop(sid, None)

View file

@ -1,11 +1,12 @@
"""Claim provenance — A-16 applied to claims this surface consumes.
`key-cape` emits `tenant` and `principal_type` as bare strings. A consumer
cannot tell a value the *directory asserted about the person* from one a
*registration supplied about the client they came through*.
`key-cape` emits `tenant_source` alongside `tenant`. Its authorization-code
handler derives `principal_type=human` from the authenticated user flow; its
client-credentials handler emits `service`. These are distinct provenance
contracts, not two properties inferred from a client registration.
`GH-DEC-2026-013` §5 requires the claim to carry its provenance. Until it does,
this surface records which route the value arrived by rather than storing an
`GH-DEC-2026-013` §5 requires the claim to carry its provenance. This surface
records which route the value arrived by rather than storing an
undifferentiated string (PR-09), and never discharges a human-in-the-loop
control on a registration-supplied assertion of humanity (PR-11,
`GH-DEC-2026-016` §5).
@ -74,9 +75,9 @@ class HumanControlNotDischargeable(Exception):
def assert_human_control_dischargeable(principal_type: Claim) -> None:
"""Guard for `GH-DEC-2026-016` §5 / PR-11.
Today `principal_type: human` is a property of the client registration, so
this raises. That is correct and deliberate: the guard exists so the
limitation is visible at the point of use rather than buried in a document.
The KeyCape login adapter supplies AUTHENTICATION only after verifying the
issuer's code-flow tokens. A bare or registration-supplied assertion still
cannot discharge the control.
"""
if principal_type.value != "human":
raise HumanControlNotDischargeable(

114
informed_decision/web.py Normal file
View file

@ -0,0 +1,114 @@
"""Browser login shell. Protected memo rendering/binding is not wired yet."""
from __future__ import annotations
import html
from http.cookies import CookieError, SimpleCookie
import json
import os
import secrets
from urllib.parse import parse_qs
from .oidc import KeyCapeLogin, LoginError, ORIGIN
FLOW_COOKIE = "__Host-infd-flow"
SESSION_COOKIE = "__Host-infd-session"
def _cookie(name: str, value: str, seconds: int) -> tuple[str, str]:
return "Set-Cookie", f"{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={seconds}"
def _one(query: str) -> dict[str, str]:
if len(query) > 8192:
raise ValueError("request too large")
values = parse_qs(query, keep_blank_values=True, max_num_fields=16)
if any(len(v) != 1 for v in values.values()):
raise ValueError("duplicate parameter")
return {k: v[0] for k, v in values.items()}
class App:
def __init__(self, login: KeyCapeLogin):
self.login = login
def __call__(self, environ, start_response):
headers = [
("Cache-Control", "no-store"), ("Pragma", "no-cache"),
("Referrer-Policy", "no-referrer"), ("X-Content-Type-Options", "nosniff"),
("Content-Security-Policy", "default-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"),
("Strict-Transport-Security", "max-age=31536000"),
]
try:
cookies = SimpleCookie()
cookies.load(environ.get("HTTP_COOKIE", ""))
sid = cookies[SESSION_COOKIE].value if SESSION_COOKIE in cookies else ""
browser = cookies[FLOW_COOKIE].value if FLOW_COOKIE in cookies else ""
if not sid.isascii() or not browser.isascii():
raise ValueError("invalid cookie")
status, body, content_type, extra = self.dispatch(environ, sid, browser)
headers.extend(extra)
except LoginError:
status, body, content_type = 401, "Sign-in could not be completed. Start a new sign-in.", "text/plain"
headers.append(_cookie(FLOW_COOKIE, "", 0))
except (ValueError, CookieError, UnicodeError):
status, body, content_type = 400, "Invalid request.", "text/plain"
raw = body.encode()
headers += [("Content-Type", content_type + "; charset=utf-8"), ("Content-Length", str(len(raw)))]
start_response(f"{status} {'OK' if status < 400 else 'ERROR'}", headers)
return [raw]
def dispatch(self, environ, sid, browser):
method, path = environ.get("REQUEST_METHOD", "GET"), environ.get("PATH_INFO", "/")
if method == "GET" and path == "/healthz":
return 200, '{"status":"ok"}', "application/json", []
if method == "GET" and path == "/readyz":
return 503, json.dumps({"status": "incomplete", "reason": "approval_path_not_connected"}), "application/json", []
if method == "GET" and path == "/auth/start":
url, browser = self.login.start()
return 303, "", "text/plain", [("Location", url), _cookie(FLOW_COOKIE, browser, 300)]
if method == "GET" and path == "/auth/callback":
params = _one(environ.get("QUERY_STRING", ""))
# Even an issuer refusal consumes state. Never echo error_description.
new_sid = self.login.finish(params.get("state", ""), browser,
"" if "error" in params else params.get("code", ""))
self.login.logout(sid)
return 303, "", "text/plain", [("Location", "/"), _cookie(FLOW_COOKIE, "", 0),
_cookie(SESSION_COOKIE, new_sid, 900)]
session = self.login.session(sid)
if method == "POST" and path == "/auth/logout":
if environ.get("HTTP_ORIGIN") != ORIGIN or not session:
return 403, "Invalid sign-out request.", "text/plain", []
length = int(environ.get("CONTENT_LENGTH") or 0)
if not 0 < length <= 2048 or environ.get("CONTENT_TYPE", "").split(";")[0] != "application/x-www-form-urlencoded":
raise ValueError("invalid body")
raw = environ["wsgi.input"].read(length)
if len(raw) != length:
raise ValueError("truncated body")
csrf = _one(raw.decode()).get("csrf", "")
if not csrf.isascii() or not secrets.compare_digest(csrf, session.csrf):
return 403, "Invalid sign-out request.", "text/plain", []
self.login.logout(sid)
return 303, "", "text/plain", [("Location", "/"), _cookie(SESSION_COOKIE, "", 0)]
if method == "GET" and path == "/":
if session:
content = (f"<p>Signed in as {html.escape(session.subject)}.</p>"
"<p>Decision review is being prepared. No approval has been recorded.</p>"
'<form method="post" action="/auth/logout">'
f'<input type="hidden" name="csrf" value="{session.csrf}">'
'<button type="submit">Sign out</button></form>')
else:
content = '<p><a href="/auth/start">Sign in with KeyCape</a></p>'
return 200, ('<!doctype html><html lang="en"><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<title>Informed Decision</title><body><h1>Informed Decision</h1>'
+ content + '</body></html>'), "text/html", []
return 404, "Not found.", "text/plain", []
def main():
from waitress import serve
# Waitress does not log request targets; a proxy must also omit callback
# query strings and cookies. No debug traceback middleware belongs here.
app = App(KeyCapeLogin(os.environ["INFD_KEYCAPE_ISSUER"]))
serve(app, host="127.0.0.1", port=8080, threads=4)