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

@ -4,12 +4,13 @@
> direction belongs in `INTENT.md`; the current stage belongs in `GOAL.md`;
> current work and gates belong in `workplans/`.
## Status — 2026-09-09
## Status — 2026-09-10
**Specification, declaration and the domain core are complete. No service is
deployed.**
**The domain core, browser sign-in shell and Approval Engine HTTP adapter
are implemented. The approval surface is not deployed.**
What exists and is tested (87 tests):
What exists and is tested (206 tests, including the explicit real-engine
component suite; 100 were present before browser integration):
- layer and stance declarations — `layer.yaml`, `pep-stance.yaml`,
`informed_decision/stance.py`, with published-equals-shipped asserted;
@ -20,11 +21,20 @@ What exists and is tested (87 tests):
`disposition.py` (the verb vocabulary and guards `G_NOAGENT`, `G_STEP`,
`G_PRES`, `G_ACK`, `G_REASONS`, `G_SEALED`), `provenance.py` (claim routes,
A-16), `evidence.py` (the local outbox and commitment records);
- `approval_client.py` — the seam to `approval-engine` plus a fake carrying its
actual refusal semantics.
- `approval_client.py` — the seam to `approval-engine` plus a fake;
- `oidc.py` and `web.py` — public-client PKCE sign-in, verified human/MFA
profile, bounded server-side sessions, protected cookies and CSRF sign-out;
- `approval_http.py` and `http_transport.py` — get-by-id and human-entry
transport, declared-control checks, real entry correlation, no consume route
or automatic mutation retry. This adapter has no public browser mutation route.
What does not exist: any HTTP surface, any persistence, any UI, any deployment.
The origin `decisions.coulomb.social` is live but serves an nginx placeholder.
Remaining: durable memo/presentation/disposition storage and transactional
evidence outbox, entitlement-before-render integration, L3 review/acknowledgment/
binding UI, independent audit delivery and native deployment proof. The existing
`Outbox` is in memory. Browser sessions are ephemeral, with no approval state.
`/readyz` returns 503 until the protected approval path is connected.
The origin `decisions.coulomb.social` still serves an nginx placeholder.
See [browser-authentication.md](docs/browser-authentication.md).
`INFD-WP-0001-T08` remains open for the live end-to-end proof, which is gated on
`APPROVAL-WP-0002-T01` and a deployed `approval-engine`.
@ -72,7 +82,7 @@ trail.
- The unreachable-engine stance map, built to v0.8 obligation 3, with
published-equals-shipped asserted by test (`tests/test_layer_conformance.py`).
**Specified, not built:**
**Built as domain operations; durable HTTP integration remains:**
- The presentation record: what was rendered, to whom, when, in which locale and
UI release.
@ -82,6 +92,9 @@ trail.
memo-level.
- The browser-facing OIDC client: authorization-code + S256 PKCE against
`key-cape`, scopes `[openid, approval:read, approval:approve]`.
**Specified, not built:**
- An L3 approver surface calling `approval-engine`'s approval-entry mutation.
- The evidence bundle as an offline-verifiable export.
@ -130,12 +143,13 @@ Stated here because a scope file that only lists capabilities overstates them.
## Open
- **The deployed origin** — the one remaining input to `T07`. `client_id` and
the callback URI must name a real origin, since redirects match exactly.
- **`audit-core` registration and cadence** — the payload is ruled
(commitment-only, with the existence assertion); the sender registration and
whether reconciliation-plus-heartbeat suits a mixed-volume source are still
`audit-core`'s to answer. Required before `T08` ships.
- **Native browser registration and human proof** — T07 supplied the real
origin and submitted the contract. T08 retains registration rollout and a real
human login accepted by the deployed Approval Engine.
- **`audit-core` custody and live delivery** — source registration and cadence
have owner returns (`AUDIT-IN-0003`, `AUDIT-WP-0009` T04/T06/T07); native
credentials, independent receipt and reconciliation still require proof before
T08 ships.
*Closed 2026-09-10:* the human token tenant (`GH-DEC-2026-013`, `key-cape`
`329e48f`) and the evidence payload question (`GH-DEC-2026-014`).

View file

@ -0,0 +1,107 @@
# Browser authentication and Approval Engine transport
Implemented under `INFD-WP-0001-T08`. The service currently supplies sign-in and
sign-out. It does not yet render memos or expose an approval-entry route.
Install and exercise from the repository:
```sh
uv venv
uv pip install -e '.[dev]'
uv run python -m pytest -q
INFD_APPROVAL_ENGINE_SOURCE=/home/worsch/approval-engine uv run python -m pytest -q
uv run python tools/smoke_browser_shell.py
INFD_KEYCAPE_ISSUER=https://kc.coulomb.social uv run informed-decision-web
```
The component checks require the explicit Approval Engine source path. They use
its actual JWT verifier and API with locally signed synthetic identity fixtures.
They are not native human authentication or custody proof. The smoke script
needs free loopback port 8080, checks the installed entrypoint and stops it. It
never follows the authorization redirect or contacts KeyCape. `--receipt PATH`
writes metadata-only results.
The entrypoint listens on `127.0.0.1:8080` for a local reverse proxy. The public
origin/callback is fixed at `https://decisions.coulomb.social/auth/callback`;
Host, forwarded headers and return URLs cannot replace it. A development HTTP
listener does not replace this HTTPS callback registration. Deployment and
registration remain pending. `/healthz` checks this process; `/readyz` returns
503 because the protected approval path is incomplete.
## Identity boundary
`KeyCapeLogin` uses a configured HTTPS issuer with its `/authorize`, `/token`
and `/jwks` endpoints. No discovery or JWT header can substitute an endpoint.
It requests only `openid approval:read approval:approve`, with public-client
S256 PKCE. State, a separate browser cookie and nonce are random; the callback
consumes its five-minute pending state before token exchange. The access token
never enters a browser cookie, HTML, URL or error message.
The verifier accepts RS256 only and checks both tokens against the issuer JWKS:
ID-token audience is the client, access-token audience is `approval-engine`.
Subject, tenant, provenance and assurance must agree. Nonce, time bounds, exact
scopes, human principal, platform tenant and the published KeyCape MFA facts are
required. JWKS refresh failure refuses login; expired cached keys are not a
fallback. Login does not decide entitlement to view or approve anything.
KeyCape source `f9812ab3b2bfe8f0817185f44071e612264ec3ee`, specifically
`src/internal/server/oidc/token.go`, is the provenance contract reviewed here:
the code handler looks up the authenticated user and emits `human`; client
credentials emit `service`. `tenant_source=directory` and `registration` stay
distinct. Unknown/default provenance is refused in this flow. A verified
registration-supplied tenant remains the bounded GH-DEC-2026-013 gap.
MFA `at` is preserved as authentication time. A successful login does not assert
that MFA meets an action-specific freshness policy; the policy integration must
use these facts for the action. No local approval verdict is inferred.
Sessions last at most fifteen minutes and never outlive either token. They are
bounded to 1,024 entries, protected by a process lock, rotated on sign-in and
deleted on sign-out/expiry. Restart requires reauthentication. This is a
single-process design; replicas need a separate shared-session design. These
sessions contain no approval current state or evidence. Cookies are Secure,
HttpOnly, SameSite=Lax with the `__Host-` prefix. Sign-out requires POST, the
exact origin and a session CSRF token. Responses prohibit caching and framing.
The proxy must omit callback query strings, bearer headers and cookies from
access logs. Waitress has no request-target access log enabled by this entrypoint.
## Approval transport boundary
`ApprovalHTTPClient` implements get-by-id and add-entry using the session's
access token. Each read reaches the engine. It requires exact
`binding.human_control=true` for this first factory integration and preserves
the native `sha256:<64 hex>` act digest. A pre-entry read rejects an undeclared
object before mutation. Identifiers cannot inject extra paths or query strings.
The default origin is HTTPS. The current in-cluster service can be wired with
explicit `allow_internal_http=True` and a fixed `.svc`/`.svc.cluster.local`
origin; that option also permits numeric loopback for component tests. Public
HTTP origins are refused. Redirects are never followed, response bodies are
limited to 256 KiB, and socket operations use a five-second timeout. No ambient
proxy credentials are inherited.
POST bodies are empty objects: Approval Engine derives identity and evidence
from its verified token and discards caller content. The adapter extracts
`(approval_id, subject_id, approved_at)` from the matching actual human entry,
never `updated_at` or local time. A `409 duplicate_approver` triggers a GET of
that original entry and returns `duplicate=True`. The eventual UI must preserve
the original presentation correlation; it cannot attach a new presentation to
an old entry just because a duplicate exists. Other conflicts remain refusals.
A transport failure after POST is ambiguous: the engine may have committed.
There is no automatic POST retry. Durable disposition processing must reconcile
the current engine entry and its correlation before reporting success or
offering a retry. A failed dependency must never be recorded as a human decline.
The client is an internal seam, not a sufficient binding flow. Before a browser
route can call it, T08 must connect:
1. The access-engine entitlement decision before rendering a named memo.
2. A durable presentation/version, actor match and required acknowledgments.
3. Dispositions and a transactional evidence outbox, including return/discuss.
4. Independent audit custody/delivery and entry-correlation reconciliation.
5. Native registered KeyCape login and the deployed Approval Engine proof.
The browser currently exposes no memo or entry route and no consume capability.
The current `evidence.Outbox` is in memory and cannot satisfy durable evidence
requirements. These remaining items stay in the active T08 record.

View file

@ -0,0 +1,58 @@
{
"schema": "helixforge.browser-authentication-source.v1",
"observed_at": "2026-09-10T19:20:43.362423+00:00",
"status": "passed",
"implementation_base": "4e103f62a0e68d9a273c0abec76ee6ab35db2531",
"contracts": {
"key-cape": "f9812ab3b2bfe8f0817185f44071e612264ec3ee",
"approval-engine": "2fdb01d42aa721f3b44f46abe018e53a7813e916"
},
"validation": {
"tests_passed": 206,
"tests_failed": 0,
"tests_skipped": 0,
"existing_tests": 100,
"real_engine_component_tests": 3,
"command": "INFD_APPROVAL_ENGINE_SOURCE=/home/worsch/approval-engine /tmp/hfact-browser-20260910/.venv/bin/python -m pytest -q",
"identity_evidence": "locally signed synthetic fixture, validated by login and actual Approval Engine JWT verifier; not a native KeyCape human login",
"local_http_smoke": {
"status": "passed",
"checks": {
"installed_entrypoint_health": true,
"fixed_issuer_authorization_redirect": true,
"exact_registered_callback_and_scopes": true,
"pkce_s256_and_browser_cookie": true,
"incomplete_approval_path_not_ready": true,
"binding_route_unavailable": true
},
"issuer_contacted": false,
"native_human_login": false,
"model_calls": 0
}
},
"source_sha256": {
"informed_decision/approval_http.py": "14187ab81f0ce2d9d61ec548ff4da8563d7d1d6084f3d91dddfba9cc90d1864d",
"informed_decision/http_transport.py": "d861f9cea798af7afeffb78b93b353ade0037b4536cf44b6e71ef7d6dc794940",
"informed_decision/oidc.py": "f68d0145e0eb08e46fbbaeaf21b34bfaf51b4fdddc247e46d57c20a19f692134",
"informed_decision/web.py": "c4eb8f828e38437aec305796adf30b947207a2d34b2e5fc9c63ed9d5eff99729",
"informed_decision/provenance.py": "de02c6bf162865ba9619d74cfd68dd0a5d7f81e6c9e427f67f848cb2c2214f83",
"pyproject.toml": "8e8e008035077e409c883fb1198387b24c4c63b37a3d695c8740a237cd3258d9",
"tests/test_approval_component.py": "eea6cd523b81fb99478b72666a50ea46d5c031b7459241e0522a2606534ecbbc",
"tests/test_approval_http.py": "81bdd8aaa0c039d6e9c37ba030a87956adf132fa2391882f18120f8ec578b4fd",
"tests/test_browser_auth.py": "01f4088968eea87c58ac0b30806111f1367349e6eaf55f1d3c59499e6c68b590",
"tests/test_http_transport.py": "314f7210748a548adebedb7c63cbc9b2b5790db00c082ef1fff2664e871e350e",
"tools/smoke_browser_shell.py": "373524286e0bd475b6c6afa974f1836f0f12a39b041e8b4682c42bdd91ebc56d"
},
"native_human_login_proven": false,
"browser_approval_route_available": false,
"deployed": false,
"factory_attempts": 0,
"paid_model_calls": 0,
"remaining_owner_record": "INFD-WP-0001-T08",
"remaining": [
"durable presentation/disposition plus transactional evidence outbox",
"entitlement before render and actor/version/ack guards in browser path",
"independent audit custody/delivery and duplicate/ambiguous entry reconciliation",
"native client registration/login and deployed Approval Engine proof"
]
}

View file

@ -2,8 +2,10 @@
**Workplan task:** `INFD-WP-0001-T07`
**For:** `key-cape` (`KEY-WP-0013-T02`, `KEY-WP-0013-T05`)
**Status:** **draft — not yet submitted.** One input outstanding: the deployed
origin (§2). Everything else is fixed and stable.
**Status:** **submitted 2026-09-10** (`INFD-WP-0001-T07` done). The
origin is live. Registration rollout and a native human token accepted by
Approval Engine remain T08; the browser login implementation is described in
[browser-authentication.md](browser-authentication.md).
**Rulings:** `GH-DEC-2026-012`, `GH-DEC-2026-013`
**Contracts cited:** `key-cape/docs/approval-engine-auth-contract.md`,
`key-cape/docs/tenant-claim-contract.md`,
@ -47,11 +49,10 @@ than reconciled afterwards.
`hub.coulomb.social`, `kc.coulomb.social`), Traefik `ingressClassName`, TLS
secret per host.
- **DNS resolves** to the cluster address as of 2026-09-10.
- **Still requires, in `railiance-apps` rather than here:** an Ingress manifest
and an issued certificate. Manifest written 2026-09-10
(`manifests/informed-decision-ingress.yaml`); not yet applied.
- **Delivered by `railiance-apps`:** Ingress and issued certificate, applied
2026-09-10 (`manifests/informed-decision-ingress.yaml`).
**The host answers. This document is ready to submit.** 2026-09-10 14:32 UTC:
**The host answers. This document has been submitted.** 2026-09-10 14:32 UTC:
`railiance-apps` applied the Ingress and cert-manager issued a Let's Encrypt
certificate for `decisions.coulomb.social`;
`GET https://decisions.coulomb.social/auth/callback` returns `200` over a
@ -146,11 +147,12 @@ Consequences accepted here:
1. **This is transitional.** When the directory carries tenants, the same
`key-cape` code stops supplying and starts enforcing agreement, with no second
migration. This registration is expected to survive that unchanged.
2. **The claim is stored with its provenance.** `key-cape` emits `tenant` as a
bare string, so a consumer cannot distinguish a directory-asserted tenant from
a registration-supplied one. Until the claim carries its own provenance
(`GH-DEC-2026-013` §5, second field deliberately undesigned), this surface
records which route the value arrived by. See `PR-09`.
2. **The claim is stored with its provenance.** Current `key-cape` emits
`tenant_source`: `directory`, `registration`, or `default`. The browser
adapter preserves the first two as distinct routes and refuses missing,
unknown or default provenance for this platform approver flow. It does not
relabel registration-supplied tenancy as a fact asserted by the directory.
See `PR-09` and `key-cape/src/internal/server/oidc/token.go`.
3. **The tenant claim is never used as the act-scope.** `binding.target` is the
act-scope and is committed inside `view_hash`. Two different facts — which
scope this act enters, and which tenant this person belongs to — must not
@ -180,5 +182,17 @@ while it stands.
| # | Item | Owner |
| --- | --- | --- |
| 1 | ~~DNS A record + Ingress + TLS for `decisions.coulomb.social`~~**done 2026-09-10**, cert issued, host answers 200 | `railiance-apps` / deployment |
| 2 | Submit this document to `key-cape`, closing `KEY-WP-0013-T02` | this repo, once 1 lands |
| 3 | Prove a token issued against the registration is accepted by `approval-engine` | `T08` |
| 2 | ~~Submit the client contract to `key-cape`~~**done**, source T07 return | this repo |
| 3 | Deploy/admit the registration and prove a native human token is accepted by `approval-engine` | `T08`, `KEY-WP-0013-T05`, `APPROVAL-WP-0002-T01` |
## 8. Humanity provenance correction — 2026-09-10
The earlier assertion that `principal_type: human` came from registration was
incorrect for the current KeyCape implementation (`f9812ab`). Its code exchange
consumes a user-authenticated PKCE session, looks up that user and emits the
literal `human`; its client-credentials handler emits `service`.
`oidc.py` verifies the fixed issuer, signature, audiences, nonce, paired subject,
exact scopes, tenant provenance and MFA facts before assigning the
`authentication-derived` route. The generic provenance guard continues refusing
registration-only assertions. This is a checked source contract, not a claim
that the current deployed issuer or a native human login has been verified here.

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)

View file

@ -3,10 +3,13 @@ name = "informed-decision"
version = "0.1.0"
description = "Presentation and binding surface for decisions — the Decision Memo and its evidence."
requires-python = ">=3.11"
dependencies = []
dependencies = ["PyJWT[crypto]>=2.10,<3", "waitress>=3,<4"]
[project.scripts]
informed-decision-web = "informed_decision.web:main"
[project.optional-dependencies]
dev = ["pytest>=8"]
dev = ["pytest>=8", "PyYAML>=6,<7"]
[build-system]
requires = ["setuptools>=68"]

View file

@ -0,0 +1,101 @@
"""Opt-in actual engine contract check, using signed synthetic identity fixtures.
INFD_APPROVAL_ENGINE_SOURCE=/path/to/approval-engine python -m pytest -q
No native credentials, human login, policy verdict or audit admission is proved.
"""
from datetime import datetime, timedelta, timezone
import io
import json
import os
from pathlib import Path
import sys
import jwt
import pytest
from informed_decision.approval_client import ApprovalEngineError
from informed_decision.approval_http import ApprovalHTTPClient
from test_browser_auth import ISSUER, IssuerFixture, finish, signing_key
from informed_decision.oidc import KeyCapeLogin
@pytest.fixture
def component(signing_key, tmp_path):
source = os.environ.get("INFD_APPROVAL_ENGINE_SOURCE")
if not source:
pytest.skip("set INFD_APPROVAL_ENGINE_SOURCE for the real-engine contract check")
assert (Path(source) / "approval_engine" / "api.py").is_file()
sys.path.insert(0, source)
from approval_engine.api import App
from approval_engine.auth import JWTAuthenticator
from approval_engine.store import Engine
issuer = IssuerFixture(signing_key)
login = KeyCapeLogin(ISSUER, transport=issuer)
sid = finish((login, issuer))
session = login.session(sid)
class Keys:
def get_signing_key_from_jwt(self, token):
# Use the exact same synthetic JWKS that the login client verified.
return jwt.PyJWK.from_dict(issuer.request("GET", ISSUER + "/jwks")[1]["keys"][0])
engine = Engine(tmp_path / "approval.db")
auth = JWTAuthenticator(issuer=ISSUER, audience="approval-engine", jwks_url=ISSUER + "/jwks", jwks_client=Keys())
app = App(engine, auth)
class Transport:
def __init__(self): self.calls = []
def request(self, method, url, *, headers=None, body=None):
self.calls.append((method, url))
payload = body or b""
environ = {"PATH_INFO": url.removeprefix("https://approval.test"), "REQUEST_METHOD": method,
"CONTENT_LENGTH": str(len(payload)), "wsgi.input": io.BytesIO(payload),
"HTTP_AUTHORIZATION": (headers or {}).get("Authorization", "")}
result = {}
def start(status, headers): result["status"] = int(status.split()[0])
raw = b"".join(app(environ, start))
return result["status"], json.loads(raw)
transport = Transport()
now = datetime.now(timezone.utc)
engine.create({"actor": "synthetic-requester", "principal": "factory-fixture",
"action": "deliver", "purpose": "local-component-proof", "target": {"resource": "fixture"}},
{"not_before": (now - timedelta(seconds=1)).isoformat(),
"expires_at": (now + timedelta(minutes=10)).isoformat()},
human_control=True, approval_id="fixture")
client = ApprovalHTTPClient("https://approval.test", session, transport=transport)
yield client, engine, transport, session, signing_key
def test_real_engine_verifies_human_access_jwt_and_duplicate_correlation(component):
client, engine, transport, session, key = component
result = client.add_entry("fixture")
assert result.subject == session.subject and result.status == "approved"
entry = engine.get("fixture").as_dict()["entries"][0]
assert result.approved_at == entry["approved_at"]
assert entry["principal_type"] == "human" and entry["evidence_ref"].startswith("jwt-sha256:")
duplicate = client.add_entry("fixture")
assert duplicate.duplicate and duplicate.correlation == result.correlation
assert len(engine.get("fixture").entries) == 1
assert not any(url.endswith("/consume") for _, url in transport.calls)
def test_real_engine_service_token_cannot_bind_declared_control(component):
client, engine, transport, session, key = component
claims = jwt.decode(session.access_token, options={"verify_signature": False})
claims["principal_type"] = "service"
service = jwt.encode(claims, key, algorithm="RS256", headers={"kid": "key-1"})
status, body = transport.request("POST", "https://approval.test/v1/approvals/fixture/entries",
headers={"Authorization": "Bearer " + service}, body=b"{}")
assert (status, body["error"]) == (403, "forbidden")
assert engine.get("fixture").entries == []
def test_real_engine_revocation_stays_a_conflict(component):
client, engine, transport, session, key = component
engine.revoke("fixture")
with pytest.raises(ApprovalEngineError) as error: client.add_entry("fixture")
assert error.value.status == 409 and error.value.reason == "conflict"
assert engine.get("fixture").entries == []

120
tests/test_approval_http.py Normal file
View file

@ -0,0 +1,120 @@
import copy
from dataclasses import replace
import time
import pytest
from informed_decision.approval_client import ApprovalEngineError
from informed_decision.approval_http import ApprovalHTTPClient
from informed_decision.http_transport import TransportError
from informed_decision.oidc import HumanSession
from informed_decision.provenance import Claim, Route
ORIGIN = "https://approval.test"
APPROVAL = {"id": "fixture", "binding": {"digest": "sha256:" + "a" * 64, "human_control": True},
"status": "approved", "updated_at": "2099-01-01T00:00:00Z", "entries": [
{"subject_id": "human-fixture", "principal_type": "human", "approved_at": "2026-09-10T00:00:00Z"}]}
def session():
return HumanSession("human-fixture", Claim("tenant:platform", Route.REGISTRATION),
Claim("human", Route.AUTHENTICATION), {}, time.time() + 900, "synthetic-access-token")
class Responses:
def __init__(self, *responses):
self.responses = list(responses)
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
result = self.responses.pop(0)
if isinstance(result, Exception): raise result
return copy.deepcopy(result)
def test_entry_uses_access_token_empty_body_and_actual_entry_correlation():
transport = Responses((200, APPROVAL), (200, APPROVAL))
client = ApprovalHTTPClient(ORIGIN, session(), transport=transport)
result = client.add_entry("fixture")
assert result.correlation == ("fixture", "human-fixture", "2026-09-10T00:00:00Z")
assert not result.duplicate
assert [x[:2] for x in transport.calls] == [("GET", ORIGIN + "/v1/approvals/fixture"),
("POST", ORIGIN + "/v1/approvals/fixture/entries")]
assert transport.calls[1][2]["body"] == b"{}"
assert transport.calls[1][2]["headers"]["Authorization"] == "Bearer synthetic-access-token"
assert not hasattr(client, "consume")
def test_duplicate_recovers_original_entry_never_invents_time_or_reposts():
transport = Responses((200, APPROVAL), (409, {"error": "duplicate_approver"}), (200, APPROVAL))
result = ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture")
assert result.duplicate and result.approved_at == APPROVAL["entries"][0]["approved_at"]
assert [x[0] for x in transport.calls] == ["GET", "POST", "GET"]
@pytest.mark.parametrize("value", [False, None, "true", 1])
def test_undeclared_or_malformed_human_control_refused_before_mutation(value):
data = copy.deepcopy(APPROVAL)
data["binding"]["human_control"] = value
transport = Responses((200, data))
with pytest.raises(ApprovalEngineError, match="invalid_approval_response"):
ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture")
assert len(transport.calls) == 1 and transport.calls[0][0] == "GET"
@pytest.mark.parametrize("value", [[], [{"subject_id": "other"}], [1], None,
[{"subject_id": "human-fixture", "principal_type": "service", "approved_at": "2026-09-10T00:00:00Z"}],
[{"subject_id": "human-fixture", "principal_type": "human", "approved_at": "2026-09-10"}],
[{"subject_id": "human-fixture", "principal_type": "human", "approved_at": None}]])
def test_missing_corresponding_entry_cannot_be_reported_as_success(value):
data = copy.deepcopy(APPROVAL)
data["entries"] = value
transport = Responses((200, APPROVAL), (200, data))
with pytest.raises(ApprovalEngineError, match="entry_correlation_missing"):
ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture")
@pytest.mark.parametrize("response,reason", [
((403, {"error": "forbidden", "message": "secret-sentinel"}), "forbidden"),
((409, {"error": "conflict"}), "conflict"),
((503, {"error": "store_unavailable"}), "store_unavailable"),
((500, {"error": ["secret-sentinel"]}), "upstream_refused"),
(TransportError("secret-sentinel"), "upstream_unavailable"),
])
def test_refusal_and_uncertain_post_are_not_retried(response, reason):
transport = Responses((200, APPROVAL), response)
with pytest.raises(ApprovalEngineError) as error:
ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture")
assert error.value.reason == reason and "secret-sentinel" not in str(error.value)
assert len(transport.calls) == 2
@pytest.mark.parametrize("identifier", ["../consume", "a/entries", "a?consume", "a%2Fentries", "", "a" * 129])
def test_identifier_cannot_inject_a_route(identifier):
transport = Responses()
with pytest.raises(ValueError):
ApprovalHTTPClient(ORIGIN, session(), transport=transport).get_approval(identifier)
assert not transport.calls
def test_session_expiry_refused_before_request():
transport = Responses()
with pytest.raises(ApprovalEngineError, match="session_expired"):
ApprovalHTTPClient(ORIGIN, replace(session(), expires_at=1), transport=transport).get_approval("fixture")
assert not transport.calls
def test_read_current_state_each_time():
revoked = {**APPROVAL, "status": "revoked"}
transport = Responses((200, APPROVAL), (200, revoked))
client = ApprovalHTTPClient(ORIGIN, session(), transport=transport)
assert client.get_approval("fixture")["status"] == "approved"
assert client.get_approval("fixture")["status"] == "revoked"
def test_cluster_http_is_explicit_and_cannot_select_a_public_http_endpoint():
with pytest.raises(ValueError): ApprovalHTTPClient("http://approval-engine.approval-engine.svc", session())
client = ApprovalHTTPClient("http://approval-engine.approval-engine.svc", session(), allow_internal_http=True)
assert client.origin.startswith("http:")
with pytest.raises(ValueError): ApprovalHTTPClient("http://attacker.test", session(), allow_internal_http=True)

302
tests/test_browser_auth.py Normal file
View file

@ -0,0 +1,302 @@
"""Signed synthetic issuer fixtures: these are not proof of a native human login."""
import base64
import copy
import hashlib
import io
import json
import time
from urllib.parse import parse_qs, urlencode, urlsplit
from cryptography.hazmat.primitives.asymmetric import rsa
import jwt
import pytest
from informed_decision.http_transport import JSONTransport, TransportError
from informed_decision.oidc import CALLBACK, CLIENT_ID, KeyCapeLogin, LoginError, ORIGIN
from informed_decision.provenance import Route
from informed_decision.web import App, FLOW_COOKIE, SESSION_COOKIE
ISSUER = "https://keycape.test"
@pytest.fixture(scope="module")
def signing_key():
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
class IssuerFixture:
def __init__(self, key):
self.key = key
self.params = None
self.calls = []
self.change = lambda tokens, claims: None
self.now = int(time.time())
def request(self, method, url, *, headers=None, body=None):
self.calls.append((method, url, headers, body))
if url == ISSUER + "/jwks":
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(self.key.public_key()))
return 200, {"keys": [{**jwk, "kid": "key-1", "alg": "RS256", "use": "sig"}]}
assert method == "POST" and url == ISSUER + "/token"
params = {k: v[0] for k, v in parse_qs(body.decode()).items()}
assert params["client_id"] == CLIENT_ID and params["redirect_uri"] == CALLBACK
assert params["grant_type"] == "authorization_code" and "client_secret" not in params
challenge = base64.urlsafe_b64encode(hashlib.sha256(params["code_verifier"].encode()).digest()).rstrip(b"=").decode()
assert challenge == self.params["code_challenge"]
claims = {"iss": ISSUER, "sub": "human-fixture", "iat": self.now, "exp": self.now + 900,
"tenant": "tenant:platform", "tenant_source": "registration", "principal_type": "human",
"roles": [], "assurance": {"at": self.now - 120, "level": "aal2", "mfa": True,
"methods": ["pwd", "otp"], "source": "key-cape"}}
payloads = {"id_token": {**copy.deepcopy(claims), "aud": CLIENT_ID, "nonce": self.params["nonce"]},
"access_token": {**copy.deepcopy(claims), "aud": "approval-engine",
"scope": "openid approval:read approval:approve"}}
tokens = {"token_type": "Bearer"}
self.change(tokens, payloads)
for name, value in payloads.items():
tokens.setdefault(name, jwt.encode(value, self.key, algorithm="RS256", headers={"kid": "key-1"}))
return 200, tokens
@pytest.fixture
def login(signing_key):
transport = IssuerFixture(signing_key)
login = KeyCapeLogin(ISSUER, transport=transport)
return login, transport
def begin(pair):
login, issuer = pair
url, browser = login.start()
issuer.params = {k: v[0] for k, v in parse_qs(urlsplit(url).query).items()}
return issuer.params["state"], browser
def finish(pair):
state, browser = begin(pair)
return pair[0].finish(state, browser, "synthetic-code")
def call(app, path="/", method="GET", cookie="", query="", body=b"", origin=None, content_type="application/x-www-form-urlencoded"):
env = {"PATH_INFO": path, "REQUEST_METHOD": method, "HTTP_COOKIE": cookie,
"QUERY_STRING": query, "wsgi.input": io.BytesIO(body), "CONTENT_LENGTH": str(len(body)),
"CONTENT_TYPE": content_type}
if origin is not None:
env["HTTP_ORIGIN"] = origin
response = {}
def start(status, headers):
response.update(status=int(status.split()[0]), headers=headers)
response["body"] = b"".join(app(env, start)).decode()
return response
def test_pkce_nonce_scope_and_imported_human_session(login):
sid = finish(login)
session = login[0].session(sid)
assert session.subject == "human-fixture"
assert session.principal_type.route is Route.AUTHENTICATION
assert session.tenant.route is Route.REGISTRATION
assert session.assurance["at"] == login[1].now - 120 # Authentication, never mint time.
assert session.access_token not in repr(session)
assert login[1].params["scope"] == "openid approval:read approval:approve"
assert login[1].params["code_challenge_method"] == "S256"
assert login[1].params["nonce"] != login[1].params["state"]
@pytest.mark.parametrize("where,key,value", [
("access_token", "aud", CLIENT_ID), ("id_token", "aud", "approval-engine"),
("access_token", "aud", ["approval-engine", CLIENT_ID]),
("access_token", "iss", "https://attacker.test"),
("id_token", "nonce", "wrong"), ("id_token", "nonce", None),
("access_token", "sub", "another-human"), ("access_token", "sub", ""),
("both", "principal_type", "service"), ("both", "principal_type", "agent"),
("both", "tenant", "tenant:coulomb"), ("both", "tenant_source", None),
("both", "tenant_source", "default"), ("both", "tenant_source", "invented"),
("both", "tenant_source", ["registration"]),
("access_token", "scope", "openid approval:read approval:approve approval:consume"),
("access_token", "scope", "openid approval:approve"), ("access_token", "scope", ["openid"]),
("access_token", "roles", None), ("access_token", "roles", [1]),
("both", "assurance", {}),
("access_token", "exp", 1), ("id_token", "exp", 1),
("access_token", "iat", 9999999999), ("access_token", "exp", "9999999999"),
])
def test_invalid_identity_refused(login, where, key, value):
def change(tokens, claims):
for name in claims if where == "both" else [where]:
claims[name][key] = value
login[1].change = change
with pytest.raises(LoginError):
finish(login)
assert not login[0]._sessions
@pytest.mark.parametrize("key,value", [
("mfa", False), ("mfa", 1), ("level", "aal1"), ("methods", ["pwd"]),
("source", "self"), ("at", 0), ("at", "123"), ("at", 9999999999),
])
def test_mfa_profile_required(login, key, value):
def change(tokens, claims):
for payload in claims.values():
payload["assurance"][key] = value
login[1].change = change
with pytest.raises(LoginError, match="MFA"):
finish(login)
def test_directory_provenance_preserved(login):
def change(tokens, claims):
for payload in claims.values():
payload["tenant_source"] = "directory"
login[1].change = change
assert login[0].session(finish(login)).tenant.route is Route.DIRECTORY
@pytest.mark.parametrize("kind", ["wrong-browser", "missing-browser", "unknown-state", "expired", "missing-code"])
def test_callback_fails_before_exchange(login, kind):
state, browser = begin(login)
code = "code"
if kind == "wrong-browser": browser = "other"
if kind == "missing-browser": browser = ""
if kind == "unknown-state": state = "unknown"
if kind == "expired": login[0].clock = lambda: time.time() + 301
if kind == "missing-code": code = ""
with pytest.raises(LoginError): login[0].finish(state, browser, code)
assert not login[1].calls
def test_callback_single_use_even_after_refusal(login):
state, browser = begin(login)
login[0].finish(state, browser, "code")
count = len(login[1].calls)
with pytest.raises(LoginError): login[0].finish(state, browser, "code")
assert len(login[1].calls) == count
@pytest.mark.parametrize("value", ["broken", "", None])
def test_bad_token_never_becomes_session(login, value):
login[1].change = lambda tokens, claims: tokens.update(access_token=value)
with pytest.raises(LoginError): finish(login)
def test_wrong_signature_refused(login):
other = rsa.generate_private_key(public_exponent=65537, key_size=2048)
def change(tokens, claims):
tokens["access_token"] = jwt.encode(claims["access_token"], other, algorithm="RS256", headers={"kid": "key-1"})
login[1].change = change
with pytest.raises(LoginError): finish(login)
@pytest.mark.parametrize("algorithm,kid", [("HS256", "key-1"), ("RS256", "unknown-key")])
def test_algorithm_confusion_and_unknown_key_refused(login, algorithm, kid):
def change(tokens, claims):
key = b"synthetic-key-material-for-this-test-only" if algorithm == "HS256" else login[1].key
tokens["access_token"] = jwt.encode(claims["access_token"], key, algorithm=algorithm, headers={"kid": kid})
login[1].change = change
with pytest.raises(LoginError): finish(login)
@pytest.mark.parametrize("failure", ["token", "jwks", "expired-jwks-cache"])
def test_dependency_outage_cannot_authenticate_from_stale_keys(login, failure):
if failure == "expired-jwks-cache":
finish(login)
login[0]._keys_until = 0
state, browser = begin(login)
original = login[1].request
def request(method, url, **kwargs):
if url.endswith("/token" if failure == "token" else "/jwks"):
raise TransportError("secret-sentinel")
return original(method, url, **kwargs)
login[1].request = request
count = len(login[0]._sessions)
with pytest.raises(LoginError) as error: login[0].finish(state, browser, "code")
assert "secret-sentinel" not in str(error.value) and len(login[0]._sessions) == count
def test_browser_subject_is_html_escaped(login):
def change(tokens, claims):
for payload in claims.values(): payload["sub"] = '<script>alert("fixture")</script>'
login[1].change = change
sid = finish(login)
body = call(App(login[0]), cookie=f"{SESSION_COOKIE}={sid}")["body"]
assert "<script>" not in body and "&lt;script&gt;" in body
def test_no_session_survives_expiry_logout_or_process_restart(login):
sid = finish(login)
login[0].logout(sid)
assert login[0].session(sid) is None
sid = finish(login)
assert KeyCapeLogin(ISSUER).session(sid) is None
login[0].clock = lambda: time.time() + 901
assert login[0].session(sid) is None
def test_pending_and_session_capacity_fail_closed(login):
login[0].capacity = 1
sid = finish(login)
assert login[0].session(sid)
with pytest.raises(LoginError, match="session capacity"): finish(login)
login[0].start()
with pytest.raises(LoginError, match="login capacity"): login[0].start()
def test_callback_browser_cookie_rotation_and_token_secrecy(login):
app = App(login[0])
old_sid = finish(login)
response = call(app, "/auth/start")
headers = dict(response["headers"])
login[1].params = {k: v[0] for k, v in parse_qs(urlsplit(headers["Location"]).query).items()}
cookie = headers["Set-Cookie"].split(";")[0]
response = call(app, "/auth/callback", cookie=cookie + f"; {SESSION_COOKIE}={old_sid}",
query=urlencode({"code": "code", "state": login[1].params["state"]}))
assert response["status"] == 303
assert dict(response["headers"])["Location"] == "/"
assert login[0].session(old_sid) is None
cookies = [v for k, v in response["headers"] if k == "Set-Cookie"]
assert all("Secure; HttpOnly; SameSite=Lax" in v and "Path=/" in v for v in cookies)
assert any(v.startswith(FLOW_COOKIE + "=;") for v in cookies)
new_cookie = next(v.split(";")[0] for v in cookies if v.startswith(SESSION_COOKIE))
response = call(app, cookie=new_cookie)
session = login[0].session(new_cookie.split("=")[1])
assert "Signed in as human-fixture" in response["body"]
assert session.access_token not in str(response)
assert dict(response["headers"])["Cache-Control"] == "no-store"
def test_issuer_error_and_duplicate_params_do_not_leak_or_exchange(login):
state, browser = begin(login)
response = call(App(login[0]), "/auth/callback", cookie=f"{FLOW_COOKIE}={browser}",
query=urlencode({"state": state, "error": "denied", "error_description": "secret-sentinel"}))
assert response["status"] == 401 and "secret-sentinel" not in str(response)
assert not login[1].calls
response = call(App(login[0]), "/auth/callback", query="code=x&code=y")
assert response["status"] == 400
@pytest.mark.parametrize("origin,csrf,expected", [(ORIGIN, "correct", 303), (ORIGIN, "wrong", 403),
(None, "correct", 403), ("https://attacker.test", "correct", 403)])
def test_logout_requires_origin_and_session_csrf(login, origin, csrf, expected):
sid = finish(login)
token = login[0].session(sid).csrf if csrf == "correct" else "wrong"
response = call(App(login[0]), "/auth/logout", method="POST", cookie=f"{SESSION_COOKIE}={sid}",
body=urlencode({"csrf": token}).encode(), origin=origin)
assert response["status"] == expected
assert bool(login[0].session(sid)) == (expected != 303)
@pytest.mark.parametrize("path,method,expected", [("/healthz", "GET", 200), ("/readyz", "GET", 503),
("/approvals/approval-fixture", "GET", 404), ("/approvals/approval-fixture/accept", "POST", 404),
("/v1/approvals/approval-fixture/entries", "POST", 404), ("/auth/logout", "GET", 404)])
def test_authentication_does_not_enable_memo_or_binding_routes(login, path, method, expected):
sid = finish(login)
assert call(App(login[0]), path, method, cookie=f"{SESSION_COOKIE}={sid}")["status"] == expected
@pytest.mark.parametrize("issuer", ["http://keycape.test", "https://user:pass@keycape.test", "https://keycape.test/path",
"https://keycape.test?redirect=bad", "https://keycape.test\n"])
def test_issuer_endpoint_must_be_fixed_https_origin(issuer):
with pytest.raises(ValueError): KeyCapeLogin(issuer)
def test_transport_refuses_cleartext_before_network():
with pytest.raises(TransportError): JSONTransport().request("POST", "http://attacker.test/token", body=b"secret")

View file

@ -0,0 +1,62 @@
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import threading
import pytest
from informed_decision.http_transport import JSONTransport, TransportError
@contextmanager
def upstream():
calls = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
calls.append((self.path, self.headers.get("Authorization")))
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", "/must-not-receive-token")
self.end_headers()
return
status, body = {
"/ok": (200, b'{"ok":true}'), "/refuse": (403, b'{"error":"forbidden"}'),
"/broken": (200, b"broken"), "/array": (200, b"[]"),
"/large": (200, b" " * 262145),
}.get(self.path, (500, b"{}"))
self.send_response(status)
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", calls
finally:
server.shutdown()
server.server_close()
thread.join()
def test_redirect_never_forwards_bearer():
with upstream() as (origin, calls):
with pytest.raises(TransportError, match="redirect"):
JSONTransport(allow_internal_http=True).request("GET", origin + "/redirect",
headers={"Authorization": "Bearer synthetic-sentinel"})
assert calls == [("/redirect", "Bearer synthetic-sentinel")]
@pytest.mark.parametrize("path", ["/broken", "/array", "/large"])
def test_invalid_or_oversize_response_refused(path):
with upstream() as (origin, calls):
with pytest.raises(TransportError):
JSONTransport(allow_internal_http=True).request("GET", origin + path)
def test_json_success_and_typed_refusal_preserved():
with upstream() as (origin, calls):
client = JSONTransport(allow_internal_http=True)
assert client.request("GET", origin + "/ok") == (200, {"ok": True})
assert client.request("GET", origin + "/refuse") == (403, {"error": "forbidden"})

View file

@ -0,0 +1,54 @@
"""Exercise the installed HTTP entrypoint with metadata-only output. Port 8080 must be free."""
import argparse, json, os, socket, subprocess, sys, time
from urllib.request import build_opener, HTTPRedirectHandler, ProxyHandler, Request
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit, parse_qs
from pathlib import Path
parser = argparse.ArgumentParser(description="Local auth-shell smoke; never follows the issuer redirect.")
parser.add_argument("--receipt", type=Path)
args = parser.parse_args()
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, *args): return None
opener = build_opener(ProxyHandler({}), NoRedirect())
with socket.socket() as sock:
sock.bind(("127.0.0.1", 8080))
env = {**os.environ, "INFD_KEYCAPE_ISSUER": "https://kc.coulomb.social"}
proc = subprocess.Popen([str(Path(sys.executable).with_name("informed-decision-web"))], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
checks = {}
try:
for attempt in range(50):
try:
with opener.open("http://127.0.0.1:8080/healthz", timeout=1) as r:
checks["installed_entrypoint_health"] = r.status == 200 and json.load(r) == {"status": "ok"}
break
except URLError:
if proc.poll() is not None: raise RuntimeError("service failed to start")
time.sleep(0.1)
assert checks.get("installed_entrypoint_health")
try: opener.open("http://127.0.0.1:8080/auth/start", timeout=2)
except HTTPError as r:
parsed=urlsplit(r.headers["Location"]); q=parse_qs(parsed.query)
checks["fixed_issuer_authorization_redirect"] = r.code==303 and parsed.scheme=="https" and parsed.netloc=="kc.coulomb.social" and parsed.path=="/authorize"
checks["exact_registered_callback_and_scopes"] = q["redirect_uri"]==["https://decisions.coulomb.social/auth/callback"] and q["scope"]==["openid approval:read approval:approve"]
checks["pkce_s256_and_browser_cookie"] = q["code_challenge_method"]==["S256"] and "Secure; HttpOnly; SameSite=Lax" in r.headers["Set-Cookie"]
r.close()
try: opener.open("http://127.0.0.1:8080/readyz", timeout=2)
except HTTPError as r:
checks["incomplete_approval_path_not_ready"] = r.code==503 and json.load(r)["reason"]=="approval_path_not_connected"
r.close()
try: opener.open(Request("http://127.0.0.1:8080/approvals/fixture/accept", data=b"{}"), timeout=2)
except HTTPError as r:
checks["binding_route_unavailable"] = r.code==404
r.close()
assert len(checks)==6 and all(checks.values())
receipt={"status":"passed", "checks":checks,"issuer_contacted":False,"native_human_login":False,"model_calls":0}
if args.receipt:
args.receipt.write_text(json.dumps(receipt,indent=2)+"\n")
print(json.dumps(receipt,indent=2))
finally:
proc.terminate()
try: proc.communicate(timeout=5)
except subprocess.TimeoutExpired:
proc.kill(); proc.communicate()

View file

@ -436,7 +436,7 @@ state_hub_task_id: "b5c1d329-9580-5672-9640-2930cbbb729a"
```
Prove the specs against reality with the thinnest possible L3 path: sign in via
`key-cape`, list approvals awaiting this principal from `approval-engine`,
`key-cape`, retrieve one named approval by id from `approval-engine`,
render one as a Decision Memo with brief, packet and highlights, acknowledge the
required highlights, and submit an approval entry with a stored presentation
record carrying `view_hash`.
@ -492,6 +492,40 @@ requires the evidence copy to reach `audit-core` independently of this
component, and the payload question is open. Design and decision request in
`docs/evidence-path-design.md`. This task must not ship before it is answered.
2026-09-10 — **browser authentication and real HTTP adapter implemented.**
`oidc.py` verifies KeyCape authorization-code/S256 PKCE, browser-bound single-use
state, ID-token nonce and client audience, access-token resource audience,
paired subject/tenant/provenance, exact scopes and MFA facts. Tokens stay in
bounded ephemeral server sessions. `web.py` supplies the sign-in shell with
secure cookies, CSRF sign-out, no token output and no memo/entry route.
`approval_http.py` reads by id and submits human entries, requires the declared
control and native digest format, and recovers duplicate correlation from the
actual stored entry. No consume API, validity cache or automatic POST retry.
206 tests pass, including three checks against Approval Engine's actual JWT
verifier/API and 100 existing domain/contract tests. Six installed-entrypoint
HTTP smoke checks pass. These use signed synthetic identity fixtures: **no
native human login, audit admission or factory execution is claimed.**
The component test caught an unprefixed-digest assumption; the adapter now
carries the native `sha256:<64 hex>` value unchanged. The package's dev extras
now include PyYAML so layer conformance cannot silently disappear in a clean
installation. Evidence: `docs/evidence/2026-09-10-browser-authentication.json`.
The earlier principal-type provenance assumption is corrected against KeyCape
`f9812ab`: its user-authenticated code flow emits `human`, while client credentials
emit `service`; `tenant_source` distinguishes directory and registration.
Unknown tenant provenance fails closed. This adopts the current issuer contract
without assuming that it has been proven on the deployed login path.
**Next within this task:** durable presentations/dispositions plus transactional
outbox; entitlement before render; connect one named memo with required
acknowledgments, accept/return/discuss and correct actor/presentation binding;
independent audit delivery; then native registered login and deployed-engine
proof. Ambiguous engine POSTs must reconcile against stored entry correlation
before a UI retry. `/readyz` deliberately remains 503 and browser entry routes
are absent until this protected path is wired. The task remains `progress`.
See `docs/browser-authentication.md`.
## Known risks
- **T02 is a hard gate.** Writing the blueprint before the layer ruling risks