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:
parent
4e103f62a0
commit
0e48355b9f
16 changed files with 1365 additions and 39 deletions
44
SCOPE.md
44
SCOPE.md
|
|
@ -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`).
|
||||
|
|
|
|||
107
docs/browser-authentication.md
Normal file
107
docs/browser-authentication.md
Normal 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.
|
||||
58
docs/evidence/2026-09-10-browser-authentication.json
Normal file
58
docs/evidence/2026-09-10-browser-authentication.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
82
informed_decision/approval_http.py
Normal file
82
informed_decision/approval_http.py
Normal 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)
|
||||
73
informed_decision/http_transport.py
Normal file
73
informed_decision/http_transport.py
Normal 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
187
informed_decision/oidc.py
Normal 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)
|
||||
|
|
@ -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
114
informed_decision/web.py
Normal 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)
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
101
tests/test_approval_component.py
Normal file
101
tests/test_approval_component.py
Normal 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
120
tests/test_approval_http.py
Normal 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
302
tests/test_browser_auth.py
Normal 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 "<script>" 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")
|
||||
62
tests/test_http_transport.py
Normal file
62
tests/test_http_transport.py
Normal 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"})
|
||||
54
tools/smoke_browser_shell.py
Normal file
54
tools/smoke_browser_shell.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue