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

@ -22,18 +22,26 @@ Two independent layers, same as the blueprint's identity separation
(`specs/ArchitectureBlueprint.md` §4.7):
1. **Workload auth (is this caller allowed to reach the service at all?)**
Today: a static shared-secret bearer token
(`QONTO_ASSISTANT_MCP_TOKEN`), checked by `BearerTokenAuthMiddleware`
in front of `/mcp`. If the env var is unset, the middleware is not
installed — used only for local fixture-backed smoke work, never for a
deployment holding real credentials.
Two mechanisms now coexist:
This is **not** the OIDC/workload-identity target the blueprint
describes — there is no OIDC issuer in this fleet yet to federate
against. A shared-secret bearer token is the deployable "or similar"
primitive for now; upgrading to real OIDC/workload identity is
fleet-level follow-on work (tracked as a Phase 2 gap, not closed by this
task).
- **key-cape IAM Profile tokens (QONTO-WP-0004-T03, current target).**
Set `QONTO_KEY_CAPE_JWKS_URL` (and, if not using the defaults,
`QONTO_KEY_CAPE_ISSUER`/`QONTO_KEY_CAPE_AUDIENCE`) and a
`KeyCapeTokenVerifier` is built and wired into both REST and MCP
(`auth.actor_claims_from_headers`). A verified `Authorization: Bearer
<jwt>` takes precedence over self-asserted headers; actor identity
(tenant, scopes, and a lane derived from the profile's `roles` claim)
comes from the verified token, not the caller's assertion. Set
`QONTO_KEY_CAPE_REQUIRED=true` to reject any request without a valid
bearer token outright, once real key-cape tokens are actually being
issued to callers — until then, leave it `false` so unconfigured
deployments keep working during rollout.
- **Shared-secret bearer token (legacy/local-dev).**
`QONTO_ASSISTANT_MCP_TOKEN`, checked by `BearerTokenAuthMiddleware` in
front of `/mcp` only (REST has no equivalent — see below). If unset,
the middleware is not installed. This predates the key-cape
integration and should be treated as fixture/local-dev only, never the
auth boundary for a deployment holding real credentials.
This token is a **service credential**, not a bank credential — it never
reaches Qonto and is never logged. Treat it like any other shared
@ -41,11 +49,15 @@ Two independent layers, same as the blueprint's identity separation
plaintext config committed to a repo.
2. **Actor identity (who is calling, for policy and audit purposes?)**
Same `X-Actor-ID` / `X-Tenant-ID` / `X-Actor-Lane` / `X-Actor-Scopes`
header convention REST already uses (`auth.actor_claims_from_headers`).
These are self-asserted today, not cryptographically bound to the bearer
token — tightening that binding is exactly what Phase 3's flex-auth
resource (`finance.qonto.read`) is for.
When a key-cape verifier is configured, actor identity comes from the
verified token's claims (`sub` → actor id, `tenant` → tenant id, `roles`
→ lane, `scope`/`scp` → scopes) — see
`src/qonto_assistant/key_cape_auth.py`. Otherwise, the same `X-Actor-ID`
/ `X-Tenant-ID` / `X-Actor-Lane` / `X-Actor-Scopes` header convention
REST and MCP both use (`auth.actor_claims_from_headers`) applies,
self-asserted and not cryptographically bound to anything — the gap
that configuring key-cape closes. Tightening this further with a live
`flex-auth` decision on `finance.qonto.read` is QONTO-WP-0004-T04.
## One shared client config snippet