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,