feat: exchange scoped approval service tokens per request
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 07:06:05 +02:00
parent 3a19069b4b
commit 7688445184
14 changed files with 859 additions and 35 deletions

View file

@ -1,12 +1,9 @@
"""Explicit KeyCape service-JWT provider for future OpenBao JWT login.
"""KeyCape service-JWT exchange shared by two separately validated profiles.
This module implements the accepted KeyCape consumer contract without wiring it
into OpenBao yet. The platform owner still needs to publish the exact OpenBao
JWT auth mount and role. Keeping provider selection separate prevents an auth
failure from falling back to bootstrap, operator, or AppRole credentials.
JWT parsing here is a claim preflight, not signature verification. OpenBao must
verify the RS256 signature against the configured issuer before issuing a token.
The OpenBao login profile and approval consumer profile have distinct clients,
audiences, tenants and scopes. Provider selection never falls back to another
identity. Token parsing is a claim preflight, not signature verification: the
receiving OpenBao or approval-engine service verifies RS256 before accepting it.
"""
from __future__ import annotations
@ -46,6 +43,7 @@ class KeyCapeServiceAuthConfig:
required_role: str = ROLE
scope: str = SCOPE
timeout_seconds: float = 10.0
max_future_iat_seconds: int = field(default=60, init=False)
def __post_init__(self) -> None:
if not self.token_url.startswith("https://"):
@ -95,7 +93,7 @@ def preflight_service_jwt(
*,
now: datetime | None = None,
) -> ServiceJWT:
"""Validate non-cryptographic JWT shape/claims before OpenBao login."""
"""Validate non-cryptographic JWT shape/claims before resource-server use."""
parts = token.split(".")
if len(parts) != 3 or not all(parts):
raise BackendError("KeyCape access token is not a compact JWT")
@ -122,8 +120,8 @@ def preflight_service_jwt(
assurance = claims.get("assurance")
if not isinstance(assurance, dict) or (
assurance.get("aal") != "AAL1"
or assurance.get("method") != "client_secret"
assurance.get("level") != "aal1"
or assurance.get("methods") != ["client_secret"]
or assurance.get("mfa") is not False
or assurance.get("source") != "key-cape"
):
@ -139,7 +137,7 @@ def preflight_service_jwt(
):
raise BackendError("KeyCape JWT iat/exp must be integer timestamps")
current = int((now or datetime.now(timezone.utc)).timestamp())
if issued_at > current + 60:
if issued_at > current + config.max_future_iat_seconds:
raise BackendError("KeyCape JWT issue time is in the future")
if expires_at <= current:
raise BackendError("KeyCape JWT has expired")
@ -187,7 +185,9 @@ class KeyCapeServiceAuthProvider:
response = self.transport(request, timeout=self.config.timeout_seconds)
with response:
status = getattr(response, "status", 200)
raw = response.read()
raw = response.read(65537)
if len(raw) > 65536:
raise BackendError("KeyCape token response exceeds size bound")
except HTTPError as e:
raise BackendError(f"KeyCape token exchange failed with HTTP {e.code}") from e
except (URLError, TimeoutError, OSError) as e: