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
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"})
|
||||
Loading…
Add table
Add a link
Reference in a new issue