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,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.