"""Create-only requester exchange from rotating projected custody. No access token or credential cache, environment delivery, refresh grant or browser identity. Public issuer endpoints and claims are pinned locally. """ import base64 from pathlib import Path from urllib.parse import urlencode, quote_plus import time import jwt from .http_transport import JSONTransport, https_origin CLIENT_ID = "informed-decision-sitting-requester" class RequesterError(Exception): """Fixed refusal; never retains an upstream body, credential or token.""" class RequesterTokenProvider: def __init__(self, issuer, secret_file, *, transport=None, clock=time.time, scope="approval:create"): if scope not in ("approval:create", "approval:read"): raise ValueError("requester scope is create or read only") self.scope = scope self.issuer = https_origin(issuer) self.secret_file = Path(secret_file) if not self.secret_file.is_absolute(): raise ValueError("absolute requester credential path required") self.transport = transport or JSONTransport() self.clock = clock def __call__(self): try: # Reopen Kubernetes's projected symlink every exchange. Never copy # custody to the evidence volume, strip it, or hold a cached token. with self.secret_file.open("rb") as handle: raw = handle.read(4097) secret = raw.decode("ascii") if not secret or len(raw) > 4096 or any(c.isspace() for c in secret): raise ValueError() credentials = base64.b64encode( (quote_plus(CLIENT_ID) + ":" + quote_plus(secret)).encode("ascii") ).decode("ascii") status, result = self.transport.request("POST", self.issuer + "/token", headers={"Content-Type": "application/x-www-form-urlencoded", "Authorization": "Basic " + credentials}, body=urlencode({"grant_type": "client_credentials", "scope": self.scope}).encode()) if (status != 200 or not isinstance(result.get("token_type"), str) or result["token_type"].lower() != "bearer" or "refresh_token" in result): raise ValueError() token = result.get("access_token") if not isinstance(token, str) or not token or len(token) > 16384: raise ValueError() header = jwt.get_unverified_header(token) if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str) or not header["kid"]: raise ValueError() status, jwks = self.transport.request("GET", self.issuer + "/jwks") if status != 200 or not isinstance(jwks.get("keys"), list): raise ValueError() keys = [key for key in jwks["keys"] if key.get("kid") == header["kid"]] if (len(keys) != 1 or keys[0].get("kty") != "RSA" or keys[0].get("use", "sig") != "sig" or keys[0].get("alg", "RS256") != "RS256"): raise ValueError() key = jwt.PyJWK.from_dict(keys[0], algorithm="RS256").key claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer, audience="approval-engine", options={"require": ["iss", "aud", "sub", "iat", "exp"], "strict_aud": True}) now = self.clock() if (claims.get("sub") != "informed-decision" or claims.get("tenant") != "tenant:platform" or claims.get("principal_type") != "service" or claims.get("roles") != [CLIENT_ID] or claims.get("scope") != self.scope or type(claims["iat"]) is not int or type(claims["exp"]) is not int or claims["iat"] > now or claims["exp"] <= now or not 0 < claims["exp"] - claims["iat"] <= 900): raise ValueError() return token except Exception: raise RequesterError("requester exchange refused") from None def compose_intake(store, token_review_call, *, issuer, secret_file, approval_origin, submitters_path, groups_path, transport=None): """Owner composition seam; caller owns CA-verified TokenReview and listener. This factory grants no submitter and starts no socket. The separate private listener and deployment remain reviewed owner configuration. """ from .intake_approval import IntakeApprovalClient, IntakeApprovalReader from .intake_service import IntakeService, TokenReview provider = RequesterTokenProvider(issuer, secret_file, transport=transport) creator = IntakeApprovalClient(approval_origin, provider, allow_internal_http=True) # present_existing only; needs approval:read on the requester client (KeyCape registration). # Until granted, that exchange is refused and only present_existing submitters fail closed. read_provider = RequesterTokenProvider(issuer, secret_file, transport=transport, scope="approval:read") reader = IntakeApprovalReader(approval_origin, read_provider, allow_internal_http=True) return IntakeService(store, TokenReview(token_review_call), creator, submitters_path=submitters_path, groups_path=groups_path, reader=reader, issuer=issuer)