feat: exchange scoped approval service tokens per request
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
3a19069b4b
commit
7688445184
14 changed files with 859 additions and 35 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue