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:
tegwick 2026-07-24 00:10:41 +02:00
parent 21c98fc062
commit aa28ef353a
9 changed files with 514 additions and 33 deletions

View file

@ -16,6 +16,7 @@ from qonto_assistant.auth import actor_claims_from_request
from qonto_assistant.config import Settings
from qonto_assistant.credentials import build_credential_provider
from qonto_assistant.errors import QontoAssistantError, UpstreamError
from qonto_assistant.key_cape_auth import KeyCapeTokenVerifier
from qonto_assistant.mcp_auth import BearerTokenAuthMiddleware
from qonto_assistant.mcp_server import create_mcp_server
from qonto_assistant.policy import PolicyEngine
@ -32,6 +33,7 @@ def create_app(
audit_logger: AuditLogger | None = None,
rate_limiter: RateLimiter | None = None,
concurrency_limiter: ConcurrencyLimiter | None = None,
key_cape_verifier: KeyCapeTokenVerifier | None = None,
) -> FastAPI:
settings = settings or Settings.from_env()
audit_logger = audit_logger or AuditLogger()
@ -41,8 +43,18 @@ def create_app(
rate_limiter=rate_limiter,
concurrency_limiter=concurrency_limiter,
)
if key_cape_verifier is None and settings.key_cape_jwks_url:
key_cape_verifier = KeyCapeTokenVerifier(
jwks_url=settings.key_cape_jwks_url,
issuer=settings.key_cape_issuer,
audience=settings.key_cape_audience,
required=settings.key_cape_required,
default_lane=settings.default_actor_lane,
timeout_seconds=settings.key_cape_timeout_seconds,
cache_seconds=settings.key_cape_cache_seconds,
)
mcp_server = create_mcp_server(settings=settings, service=service)
mcp_server = create_mcp_server(settings=settings, service=service, key_cape_verifier=key_cape_verifier)
mcp_app = mcp_server.streamable_http_app()
if settings.mcp_auth_token:
mcp_app.add_middleware(BearerTokenAuthMiddleware, token=settings.mcp_auth_token)
@ -78,7 +90,7 @@ def create_app(
@app.get("/v1/accounts")
async def get_accounts(request: Request) -> JSONResponse:
claims = actor_claims_from_request(request, settings)
claims = actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
request_id = _request_id(request)
payload = await run_in_threadpool(service.get_accounts, claims=claims, request_id=request_id)
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
@ -93,7 +105,7 @@ def create_app(
status: str | None = "completed",
side: str | None = None,
) -> JSONResponse:
claims = actor_claims_from_request(request, settings)
claims = actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
request_id = _request_id(request)
payload = await run_in_threadpool(
service.list_transactions,
@ -114,7 +126,7 @@ def create_app(
window_days: int = 31,
page_size: int = 50,
) -> JSONResponse:
claims = actor_claims_from_request(request, settings)
claims = actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
request_id = _request_id(request)
payload = await run_in_threadpool(
service.get_snapshot,

View file

@ -6,14 +6,32 @@ from fastapi import Request
from qonto_assistant.config import Settings
from qonto_assistant.contracts import ActorClaims
from qonto_assistant.key_cape_auth import KeyCapeAuthError, KeyCapeTokenVerifier
def actor_claims_from_headers(headers: Mapping[str, str], settings: Settings) -> ActorClaims:
def actor_claims_from_headers(
headers: Mapping[str, str],
settings: Settings,
*,
key_cape_verifier: KeyCapeTokenVerifier | None = None,
) -> ActorClaims:
"""Shared REST/MCP claims parsing so both transports enforce identical actor identity.
Real workload/OIDC auth for the MCP transport is QONTO-WP-0003-T03; until
then MCP callers use the same X-Actor-* header convention as REST.
When a `KeyCapeTokenVerifier` is configured (QONTO-WP-0004-T03), a
verified `Authorization: Bearer <jwt>` takes precedence over the
self-asserted `X-Actor-*` header convention -- closing the gap
`docs/mcp-integration.md` called out explicitly. If the verifier is
marked `required`, a missing or invalid bearer token is rejected
outright rather than silently falling back to self-asserted headers.
"""
authorization = headers.get("authorization", "")
if authorization.lower().startswith("bearer ") and key_cape_verifier is not None:
token = authorization.split(" ", 1)[1].strip()
return key_cape_verifier.verify(token)
if key_cape_verifier is not None and key_cape_verifier.required:
raise KeyCapeAuthError("bearer_token_required")
actor_id = headers.get("x-actor-id", "anonymous")
tenant_id = headers.get("x-tenant-id", settings.default_tenant_id)
lane = headers.get("x-actor-lane", settings.default_actor_lane)
@ -22,5 +40,10 @@ def actor_claims_from_headers(headers: Mapping[str, str], settings: Settings) ->
return ActorClaims(actor_id=actor_id, tenant_id=tenant_id, lane=lane, scopes=scopes)
def actor_claims_from_request(request: Request, settings: Settings) -> ActorClaims:
return actor_claims_from_headers(request.headers, settings)
def actor_claims_from_request(
request: Request,
settings: Settings,
*,
key_cape_verifier: KeyCapeTokenVerifier | None = None,
) -> ActorClaims:
return actor_claims_from_headers(request.headers, settings, key_cape_verifier=key_cape_verifier)

View file

@ -32,6 +32,12 @@ class Settings:
deny_escalation_threshold: int
deny_escalation_window_seconds: int
deny_escalation_lockout_seconds: int
key_cape_jwks_url: str | None
key_cape_issuer: str
key_cape_audience: str
key_cape_required: bool
key_cape_cache_seconds: float
key_cape_timeout_seconds: float
credential_source: str
openbao_path: str
openbao_command: str
@ -74,6 +80,12 @@ class Settings:
deny_escalation_threshold=int(os.getenv("QONTO_DENY_ESCALATION_THRESHOLD", "3")),
deny_escalation_window_seconds=int(os.getenv("QONTO_DENY_ESCALATION_WINDOW_SECONDS", "60")),
deny_escalation_lockout_seconds=int(os.getenv("QONTO_DENY_ESCALATION_LOCKOUT_SECONDS", "300")),
key_cape_jwks_url=os.getenv("QONTO_KEY_CAPE_JWKS_URL") or None,
key_cape_issuer=os.getenv("QONTO_KEY_CAPE_ISSUER", "https://key-cape.netkingdom"),
key_cape_audience=os.getenv("QONTO_KEY_CAPE_AUDIENCE", "qonto-assistant"),
key_cape_required=os.getenv("QONTO_KEY_CAPE_REQUIRED", "false").lower() == "true",
key_cape_cache_seconds=float(os.getenv("QONTO_KEY_CAPE_CACHE_SECONDS", "300")),
key_cape_timeout_seconds=float(os.getenv("QONTO_KEY_CAPE_TIMEOUT_SECONDS", "5")),
credential_source=os.getenv("QONTO_CREDENTIAL_SOURCE", "env"),
openbao_path=os.getenv("QONTO_OPENBAO_PATH", "tenants/binky/qonto-api"),
openbao_command=os.getenv("QONTO_OPENBAO_COMMAND", "bao"),

View file

@ -0,0 +1,151 @@
from __future__ import annotations
import time
from collections.abc import Sequence
from typing import Any
import httpx
import jwt
from jwt.algorithms import RSAAlgorithm
from qonto_assistant.contracts import ActorClaims
from qonto_assistant.errors import QontoAssistantError
# net-kingdom/canon/standards/iam-profile_v0.3.md "Core Claims" table --
# the subset this verifier requires to be present on every token.
REQUIRED_CLAIMS: tuple[str, ...] = ("iss", "sub", "aud", "exp", "iat", "tenant", "principal_type")
# AutonomyPolicy lanes (binky-control/AutonomyPolicy.md), not part of the
# IAM Profile itself. Until a canonical claim-to-lane mapping exists, this
# verifier looks for a lane name inside the profile's `roles` claim and
# falls back to the service's configured default -- documented here rather
# than silently defaulting for every token.
_KNOWN_LANES: tuple[str, ...] = ("green", "blue", "yellow", "orange", "red")
class KeyCapeAuthError(QontoAssistantError):
error_code = "key_cape_auth_failed"
status_code = 401
def __init__(self, message: str) -> None:
super().__init__(message)
self.error_code = "key_cape_auth_failed"
class KeyCapeTokenVerifier:
"""Verifies key-cape-issued IAM Profile tokens against its JWKS endpoint.
Fetches and caches signing keys directly over httpx (matching this
codebase's existing FlexAuthCheckClient/QontoClient pattern) rather than
PyJWT's built-in network-fetching PyJWKClient, so tests can inject a
MockTransport the same way the rest of the codebase does.
"""
def __init__(
self,
*,
jwks_url: str,
issuer: str,
audience: str,
required: bool,
default_lane: str = "green",
timeout_seconds: float = 5.0,
cache_seconds: float = 300.0,
transport: httpx.BaseTransport | None = None,
clock: Any = time.monotonic,
) -> None:
self.jwks_url = jwks_url
self.issuer = issuer
self.audience = audience
self.required = required
self.default_lane = default_lane
self.cache_seconds = cache_seconds
self.clock = clock
self._client = httpx.Client(timeout=httpx.Timeout(timeout_seconds), transport=transport)
self._cached_keys: dict[str, Any] = {}
self._cached_at: float | None = None
def close(self) -> None:
self._client.close()
def verify(self, token: str) -> ActorClaims:
self._ensure_keys()
try:
header = jwt.get_unverified_header(token)
except jwt.InvalidTokenError as exc:
raise KeyCapeAuthError("malformed_token") from exc
kid = header.get("kid")
key = self._cached_keys.get(kid) if kid else None
if key is None:
# Key rotation: force one refresh before giving up.
self._refresh_keys()
key = self._cached_keys.get(kid) if kid else None
if key is None:
raise KeyCapeAuthError("unknown_signing_key")
try:
claims = jwt.decode(
token,
key=key,
algorithms=["RS256"],
audience=self.audience,
issuer=self.issuer,
options={"require": list(REQUIRED_CLAIMS)},
)
except jwt.InvalidTokenError as exc:
raise KeyCapeAuthError(f"invalid_token:{exc}") from exc
return ActorClaims(
actor_id=str(claims["sub"]),
tenant_id=str(claims["tenant"]),
lane=_lane_from_roles(claims.get("roles"), default=self.default_lane),
scopes=frozenset(_coerce_scopes(claims.get("scope") or claims.get("scp"))),
)
def _ensure_keys(self) -> None:
if self._cached_keys and self._cached_at is not None:
if (self.clock() - self._cached_at) < self.cache_seconds:
return
self._refresh_keys()
def _refresh_keys(self) -> None:
try:
response = self._client.get(self.jwks_url)
response.raise_for_status()
payload = response.json()
except httpx.HTTPError as exc:
raise KeyCapeAuthError("jwks_unavailable") from exc
keys: dict[str, Any] = {}
for jwk in payload.get("keys", []):
kid = jwk.get("kid")
if not kid:
continue
try:
keys[kid] = RSAAlgorithm.from_jwk(jwk)
except (ValueError, TypeError):
continue
if keys:
self._cached_keys = keys
self._cached_at = self.clock()
def _coerce_scopes(value: Any) -> Sequence[str]:
if isinstance(value, str):
return [item for item in value.split() if item]
if isinstance(value, list):
return [str(item) for item in value]
return []
def _lane_from_roles(roles: Any, *, default: str) -> str:
if not isinstance(roles, list):
return default
lowered = {str(role).lower() for role in roles}
for lane in _KNOWN_LANES:
if lane in lowered:
return lane
return default

View file

@ -10,17 +10,25 @@ from starlette.applications import Starlette
from qonto_assistant import __version__
from qonto_assistant.auth import actor_claims_from_headers
from qonto_assistant.config import Settings
from qonto_assistant.key_cape_auth import KeyCapeTokenVerifier
from qonto_assistant.service import CapabilityService
def create_mcp_server(*, settings: Settings, service: CapabilityService | None = None) -> FastMCP:
def create_mcp_server(
*,
settings: Settings,
service: CapabilityService | None = None,
key_cape_verifier: KeyCapeTokenVerifier | None = None,
) -> FastMCP:
"""Build the MCP adapter on the same capability core as the REST surface.
Every tool here maps 1:1 to a REST-allowed capability id and goes through
`CapabilityService`, which in turn calls the shared
`PolicyEngine.decide()` -- no separate MCP policy path. Client auth is
still the simple X-Actor-* header convention (QONTO-WP-0003-T03 upgrades
this to real OIDC/workload auth); no bank secrets are ever exposed here.
the same seam REST uses (`auth.actor_claims_from_headers`): a verified
key-cape bearer token when `key_cape_verifier` is configured
(QONTO-WP-0004-T03), else the X-Actor-* header convention. No bank
secrets are ever exposed here.
"""
server = FastMCP(
name=settings.service_name,
@ -45,7 +53,7 @@ def create_mcp_server(*, settings: Settings, service: CapabilityService | None =
@server.tool()
def qonto_org_summary(ctx: Context) -> dict[str, Any]:
"""Organization name and per-account balances (org_summary capability)."""
claims = _claims(ctx, settings)
claims = _claims(ctx, settings, key_cape_verifier)
return service.get_accounts(claims=claims, request_id=_request_id(ctx), protocol="mcp")
@server.tool()
@ -59,7 +67,7 @@ def create_mcp_server(*, settings: Settings, service: CapabilityService | None =
side: str | None = None,
) -> dict[str, Any]:
"""Capped, filtered transaction history (list_transactions capability)."""
claims = _claims(ctx, settings)
claims = _claims(ctx, settings, key_cape_verifier)
return service.list_transactions(
claims=claims,
request_id=_request_id(ctx),
@ -79,7 +87,7 @@ def create_mcp_server(*, settings: Settings, service: CapabilityService | None =
page_size: int = 50,
) -> dict[str, Any]:
"""Normalized recurring-cost hints, not a raw export (cost_run_rate_hints capability)."""
claims = _claims(ctx, settings)
claims = _claims(ctx, settings, key_cape_verifier)
return service.get_cost_run_rate_hints(
claims=claims,
request_id=_request_id(ctx),
@ -95,9 +103,9 @@ def mcp_asgi_app(*, settings: Settings, service: CapabilityService | None = None
return create_mcp_server(settings=settings, service=service).streamable_http_app()
def _claims(ctx: Context, settings: Settings):
def _claims(ctx: Context, settings: Settings, key_cape_verifier: KeyCapeTokenVerifier | None = None):
headers = _headers_from_context(ctx)
return actor_claims_from_headers(headers, settings)
return actor_claims_from_headers(headers, settings, key_cape_verifier=key_cape_verifier)
def _headers_from_context(ctx: Context) -> Mapping[str, str]: