QONTO-WP-0004-T03: verify key-cape IAM Profile tokens
Replaces the interim shared-secret bearer token's role as the identity
boundary with real key-cape JWKS-based verification, closing the gap
docs/mcp-integration.md called out explicitly ("no OIDC issuer exists
in this fleet yet") -- key-cape's /jwks is a standard RS256 endpoint
and needed no key-cape-side work to consume.
KeyCapeTokenVerifier fetches and caches signing keys over httpx
(matching FlexAuthCheckClient's pattern elsewhere in this codebase),
validates iss/aud/exp and the IAM Profile v0.3 required claims, and
derives ActorClaims from the token (tenant, scopes, and a lane
inferred from the roles claim). Wired into auth.actor_claims_from_headers,
the single seam both REST and MCP already used -- a verified bearer
token now takes precedence over self-asserted X-Actor-* headers, and
can be made mandatory via QONTO_KEY_CAPE_REQUIRED once real tokens are
issued to callers. Off by default (no QONTO_KEY_CAPE_JWKS_URL set) so
existing deployments are unaffected until configured.
The QONTO_ASSISTANT_MCP_TOKEN shared secret remains as a documented
local-dev/legacy fallback, not the auth boundary going forward.
Verified: 13 new tests (test_key_cape_auth.py) covering valid/expired/
wrong-audience/wrong-issuer/missing-claim/unknown-key/rotated-key
tokens plus the auth.py precedence and required-vs-optional
integration paths, using a real generated RSA keypair and JWKS served
over httpx.MockTransport. Full suite -> 52 passed; REST and MCP smokes
both still pass against fixtures; compileall clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
21c98fc062
commit
aa28ef353a
9 changed files with 514 additions and 33 deletions
|
|
@ -38,6 +38,12 @@ def _settings() -> Settings:
|
|||
deny_escalation_threshold=3,
|
||||
deny_escalation_window_seconds=60,
|
||||
deny_escalation_lockout_seconds=300,
|
||||
key_cape_jwks_url=None,
|
||||
key_cape_issuer="https://key-cape.netkingdom",
|
||||
key_cape_audience="qonto-assistant",
|
||||
key_cape_required=False,
|
||||
key_cape_cache_seconds=300,
|
||||
key_cape_timeout_seconds=5,
|
||||
credential_source="env",
|
||||
openbao_path="tenants/binky/qonto-api",
|
||||
openbao_command="bao",
|
||||
|
|
|
|||
256
tests/test_key_cape_auth.py
Normal file
256
tests/test_key_cape_auth.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
|
||||
from qonto_assistant.auth import actor_claims_from_headers
|
||||
from qonto_assistant.config import Settings
|
||||
from qonto_assistant.key_cape_auth import KeyCapeAuthError, KeyCapeTokenVerifier
|
||||
|
||||
ISSUER = "https://key-cape.netkingdom.test"
|
||||
AUDIENCE = "qonto-assistant"
|
||||
KID = "test-key-1"
|
||||
|
||||
|
||||
def _keypair() -> tuple[rsa.RSAPrivateKey, dict]:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
jwk = json.loads(RSAAlgorithm.to_jwk(private_key.public_key()))
|
||||
jwk["kid"] = KID
|
||||
jwk["use"] = "sig"
|
||||
jwk["alg"] = "RS256"
|
||||
return private_key, jwk
|
||||
|
||||
|
||||
def _jwks_transport(jwk: dict, *, calls: list[int] | None = None) -> httpx.MockTransport:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if calls is not None:
|
||||
calls.append(1)
|
||||
return httpx.Response(200, json={"keys": [jwk]})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def _token(private_key: rsa.RSAPrivateKey, *, kid: str = KID, **claim_overrides) -> str:
|
||||
now = datetime.now(UTC)
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"sub": "agent-harness-binky",
|
||||
"aud": AUDIENCE,
|
||||
"exp": int((now + timedelta(minutes=5)).timestamp()),
|
||||
"iat": int(now.timestamp()),
|
||||
"tenant": "tenant:friendly:binky",
|
||||
"principal_type": "agent",
|
||||
"groups": ["group:qonto-assistant-readers"],
|
||||
"roles": ["blue"],
|
||||
"scope": "finance.qonto.read",
|
||||
}
|
||||
claims.update(claim_overrides)
|
||||
return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": kid})
|
||||
|
||||
|
||||
def _verifier(jwk: dict, *, required: bool = False, calls: list[int] | None = None) -> KeyCapeTokenVerifier:
|
||||
return KeyCapeTokenVerifier(
|
||||
jwks_url="https://key-cape.netkingdom.test/jwks",
|
||||
issuer=ISSUER,
|
||||
audience=AUDIENCE,
|
||||
required=required,
|
||||
transport=_jwks_transport(jwk, calls=calls),
|
||||
)
|
||||
|
||||
|
||||
def test_verify_accepts_valid_token() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
verifier = _verifier(jwk)
|
||||
|
||||
claims = verifier.verify(_token(private_key))
|
||||
|
||||
assert claims.actor_id == "agent-harness-binky"
|
||||
assert claims.tenant_id == "tenant:friendly:binky"
|
||||
assert claims.lane == "blue"
|
||||
assert "finance.qonto.read" in claims.scopes
|
||||
|
||||
|
||||
def test_verify_rejects_expired_token() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
verifier = _verifier(jwk)
|
||||
expired = _token(private_key, exp=int(time.time()) - 60)
|
||||
|
||||
with pytest.raises(KeyCapeAuthError):
|
||||
verifier.verify(expired)
|
||||
|
||||
|
||||
def test_verify_rejects_wrong_audience() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
verifier = _verifier(jwk)
|
||||
wrong_aud = _token(private_key, aud="some-other-service")
|
||||
|
||||
with pytest.raises(KeyCapeAuthError):
|
||||
verifier.verify(wrong_aud)
|
||||
|
||||
|
||||
def test_verify_rejects_wrong_issuer() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
verifier = _verifier(jwk)
|
||||
wrong_iss = _token(private_key, iss="https://not-key-cape.test")
|
||||
|
||||
with pytest.raises(KeyCapeAuthError):
|
||||
verifier.verify(wrong_iss)
|
||||
|
||||
|
||||
def test_verify_rejects_missing_required_claim() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
verifier = _verifier(jwk)
|
||||
now = datetime.now(UTC)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"sub": "agent-harness-binky",
|
||||
"aud": AUDIENCE,
|
||||
"exp": int((now + timedelta(minutes=5)).timestamp()),
|
||||
"iat": int(now.timestamp()),
|
||||
# tenant and principal_type deliberately omitted
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
headers={"kid": KID},
|
||||
)
|
||||
|
||||
with pytest.raises(KeyCapeAuthError):
|
||||
verifier.verify(token)
|
||||
|
||||
|
||||
def test_verify_rejects_unknown_signing_key() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
other_key, _ = _keypair()
|
||||
verifier = _verifier(jwk)
|
||||
token_from_other_key = _token(other_key, kid="a-different-kid")
|
||||
|
||||
with pytest.raises(KeyCapeAuthError):
|
||||
verifier.verify(token_from_other_key)
|
||||
|
||||
|
||||
def test_verify_falls_back_to_default_lane_when_roles_dont_match() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
verifier = _verifier(jwk)
|
||||
token = _token(private_key, roles=["some-other-role"])
|
||||
|
||||
claims = verifier.verify(token)
|
||||
|
||||
assert claims.lane == "green"
|
||||
|
||||
|
||||
def test_jwks_cached_across_calls() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
calls: list[int] = []
|
||||
verifier = _verifier(jwk, calls=calls)
|
||||
|
||||
verifier.verify(_token(private_key))
|
||||
verifier.verify(_token(private_key))
|
||||
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_jwks_refetched_on_unknown_kid_before_failing() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
calls: list[int] = []
|
||||
verifier = _verifier(jwk, calls=calls)
|
||||
verifier.verify(_token(private_key)) # primes the cache with one fetch
|
||||
|
||||
with pytest.raises(KeyCapeAuthError):
|
||||
verifier.verify(_token(private_key, kid="rotated-kid-not-in-jwks"))
|
||||
|
||||
# one fetch to prime the cache, one forced refresh attempt on the miss
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
# --- auth.py integration ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
service_name="qonto-assistant",
|
||||
default_tenant_id="binky",
|
||||
default_actor_lane="green",
|
||||
required_scope="finance.qonto.read",
|
||||
enforce_scope=False,
|
||||
policy_file=None, # unused by these tests
|
||||
qonto_base_url="https://example.test",
|
||||
qonto_fixture_dir=None,
|
||||
qonto_auth_mode="legacy_api_key",
|
||||
qonto_organization_path="/v2/organization",
|
||||
qonto_transactions_path="/v2/transactions",
|
||||
qonto_timeout_seconds=1,
|
||||
qonto_max_retries=0,
|
||||
qonto_secret_ttl_seconds=60,
|
||||
rate_limit_requests=20,
|
||||
rate_limit_window_seconds=60,
|
||||
max_concurrency=4,
|
||||
deny_escalation_enabled=True,
|
||||
deny_escalation_threshold=3,
|
||||
deny_escalation_window_seconds=60,
|
||||
deny_escalation_lockout_seconds=300,
|
||||
key_cape_jwks_url=None,
|
||||
key_cape_issuer=ISSUER,
|
||||
key_cape_audience=AUDIENCE,
|
||||
key_cape_required=False,
|
||||
key_cape_cache_seconds=300,
|
||||
key_cape_timeout_seconds=5,
|
||||
credential_source="env",
|
||||
openbao_path="tenants/binky/qonto-api",
|
||||
openbao_command="bao",
|
||||
openbao_timeout_seconds=5,
|
||||
mcp_auth_token=None,
|
||||
host="127.0.0.1",
|
||||
port=8080,
|
||||
)
|
||||
|
||||
|
||||
def test_bearer_token_takes_precedence_over_self_asserted_headers() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
verifier = _verifier(jwk)
|
||||
token = _token(private_key)
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"x-actor-id": "someone-self-asserted",
|
||||
"x-tenant-id": "not-binky",
|
||||
}
|
||||
|
||||
claims = actor_claims_from_headers(headers, _settings(), key_cape_verifier=verifier)
|
||||
|
||||
assert claims.actor_id == "agent-harness-binky"
|
||||
assert claims.tenant_id == "tenant:friendly:binky"
|
||||
|
||||
|
||||
def test_required_verifier_rejects_missing_bearer_token() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
verifier = _verifier(jwk, required=True)
|
||||
|
||||
with pytest.raises(KeyCapeAuthError):
|
||||
actor_claims_from_headers({"x-actor-id": "someone"}, _settings(), key_cape_verifier=verifier)
|
||||
|
||||
|
||||
def test_optional_verifier_falls_back_to_self_asserted_headers_when_absent() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
verifier = _verifier(jwk, required=False)
|
||||
|
||||
claims = actor_claims_from_headers(
|
||||
{"x-actor-id": "someone", "x-tenant-id": "binky"}, _settings(), key_cape_verifier=verifier
|
||||
)
|
||||
|
||||
assert claims.actor_id == "someone"
|
||||
assert claims.tenant_id == "binky"
|
||||
|
||||
|
||||
def test_invalid_bearer_token_is_rejected_even_when_not_required() -> None:
|
||||
private_key, jwk = _keypair()
|
||||
verifier = _verifier(jwk, required=False)
|
||||
|
||||
with pytest.raises(KeyCapeAuthError):
|
||||
actor_claims_from_headers(
|
||||
{"authorization": "Bearer not-a-real-jwt"}, _settings(), key_cape_verifier=verifier
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue