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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
151
src/qonto_assistant/key_cape_auth.py
Normal file
151
src/qonto_assistant/key_cape_auth.py
Normal 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
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue