"""KeyCape public-client authorization-code/PKCE login, never local identity. The issuer and its fixed endpoints are trusted deployment configuration. The human provenance mapping is specific to KeyCape's user-authenticated code flow in internal/server/oidc/token.go, not a property inferred from client metadata. """ from __future__ import annotations import base64 import hashlib import secrets import threading import time from dataclasses import dataclass, field from urllib.parse import urlencode import jwt from .approval_client import REQUIRED_SCOPES from .http_transport import JSONTransport, TransportError, https_origin from .provenance import Claim, Route, assert_human_control_dischargeable CLIENT_ID = "informed-decision-approver" ORIGIN = "https://decisions.coulomb.social" CALLBACK = ORIGIN + "/auth/callback" class LoginError(Exception): """Safe, fixed errors: never include issuer response bodies or tokens.""" @dataclass(frozen=True) class HumanSession: subject: str tenant: Claim principal_type: Claim assurance: dict = field(repr=False) expires_at: float access_token: str = field(repr=False) csrf: str = field(default_factory=lambda: secrets.token_urlsafe(32), repr=False) class KeyCapeLogin: """Single-process, bounded ephemeral sessions. Restart means reauthenticate. No refresh token, approval state, memo, or evidence is stored here. Multiple replicas need a separately designed shared session store before deployment. """ def __init__(self, issuer: str, *, transport=None, clock=time.time, capacity=1024): self.issuer = https_origin(issuer) self.transport = transport or JSONTransport() self.clock = clock self.capacity = capacity self._lock = threading.RLock() self._pending: dict[str, tuple] = {} self._sessions: dict[str, HumanSession] = {} self._keys: dict[str, object] = {} self._keys_until = 0.0 def _prune(self): now = self.clock() self._pending = {k: v for k, v in self._pending.items() if v[0] > now} self._sessions = {k: v for k, v in self._sessions.items() if v.expires_at > now} def start(self) -> tuple[str, str]: with self._lock: self._prune() if len(self._pending) >= self.capacity: raise LoginError("login capacity reached") state, browser, nonce = (secrets.token_urlsafe(32) for _ in range(3)) verifier = secrets.token_urlsafe(64) self._pending[state] = (self.clock() + 300, browser, nonce, verifier) challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() return self.issuer + "/authorize?" + urlencode({ "response_type": "code", "client_id": CLIENT_ID, "redirect_uri": CALLBACK, "scope": " ".join(REQUIRED_SCOPES), "state": state, "nonce": nonce, "code_challenge": challenge, "code_challenge_method": "S256", }), browser def _decode(self, token: str, audience: str) -> dict: if not isinstance(token, str) or not token or len(token) > 32768: raise LoginError("token verification failed") try: header = jwt.get_unverified_header(token) kid = header.get("kid") if header.get("alg") != "RS256" or not isinstance(kid, str) or not kid: raise LoginError("token verification failed") with self._lock: if self.clock() >= self._keys_until or kid not in self._keys: status, data = self.transport.request("GET", self.issuer + "/jwks") if status != 200 or not isinstance(data.get("keys"), list): raise LoginError("token verification failed") keys = {} for raw in data["keys"]: if (raw.get("kty") != "RSA" or raw.get("use", "sig") != "sig" or raw.get("alg", "RS256") != "RS256"): continue key = jwt.PyJWK.from_dict(raw, algorithm="RS256") if not key.key_id or key.key_id in keys: raise LoginError("token verification failed") keys[key.key_id] = key.key self._keys, self._keys_until = keys, self.clock() + 300 key = self._keys[kid] claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer, audience=audience, leeway=30, options={ "require": ["iss", "aud", "sub", "exp", "iat"], "strict_aud": True, }) if (not isinstance(claims["sub"], str) or not claims["sub"] or any(type(claims[k]) is not int for k in ("iat", "exp")) or claims["exp"] <= self.clock() or claims["exp"] <= claims["iat"]): raise LoginError("token verification failed") return claims except (jwt.PyJWTError, TransportError, KeyError, TypeError, ValueError, AttributeError): raise LoginError("token verification failed") from None def finish(self, state: str, browser: str, code: str) -> str: with self._lock: self._prune() pending = self._pending.pop(state, None) if not pending or not browser or not secrets.compare_digest(browser, pending[1]): raise LoginError("login state invalid or expired") if not code or len(code) > 4096: raise LoginError("authorization code missing or invalid") try: status, tokens = self.transport.request("POST", self.issuer + "/token", headers={"Content-Type": "application/x-www-form-urlencoded"}, body=urlencode({"grant_type": "authorization_code", "client_id": CLIENT_ID, "redirect_uri": CALLBACK, "code_verifier": pending[3], "code": code}).encode()) except TransportError: raise LoginError("token exchange unavailable") from None token_type = tokens.get("token_type") if status != 200 or not isinstance(token_type, str) or token_type.lower() != "bearer": raise LoginError("token exchange refused") identity = self._decode(tokens.get("id_token"), CLIENT_ID) access = self._decode(tokens.get("access_token"), "approval-engine") if identity.get("nonce") != pending[2] or identity["sub"] != access["sub"]: raise LoginError("login identity mismatch") for name in ("tenant", "tenant_source", "principal_type", "assurance"): if identity.get(name) != access.get(name): raise LoginError("login identity mismatch") scope = access.get("scope") if not isinstance(scope, str) or set(scope.split()) != set(REQUIRED_SCOPES): raise LoginError("unexpected token scopes") if access.get("principal_type") != "human" or access.get("tenant") != "tenant:platform": raise LoginError("human approver profile required") if (not isinstance(access.get("roles"), list) or any(not isinstance(x, str) for x in access["roles"])): raise LoginError("human approver profile required") assurance = access.get("assurance") if (not isinstance(assurance, dict) or assurance.get("level") != "aal2" or assurance.get("mfa") is not True or assurance.get("source") != "key-cape" or assurance.get("methods") != ["pwd", "otp"] or type(assurance.get("at")) is not int or assurance["at"] <= 0 or assurance["at"] > min(access["iat"], self.clock()) + 30): raise LoginError("verified MFA facts required") tenant_source = access.get("tenant_source") if not isinstance(tenant_source, str): raise LoginError("tenant provenance required") tenant_route = {"directory": Route.DIRECTORY, "registration": Route.REGISTRATION, "default": Route.INDETERMINATE}.get(tenant_source, Route.INDETERMINATE) if tenant_route is Route.INDETERMINATE: raise LoginError("tenant provenance required") human = Claim("human", Route.AUTHENTICATION) assert_human_control_dischargeable(human) session = HumanSession(access["sub"], Claim(access["tenant"], tenant_route), human, dict(assurance), min(identity["exp"], access["exp"], self.clock() + 900), tokens["access_token"]) with self._lock: self._prune() if len(self._sessions) >= self.capacity: raise LoginError("session capacity reached") sid = secrets.token_urlsafe(32) self._sessions[sid] = session return sid def session(self, sid: str) -> HumanSession | None: with self._lock: self._prune() return self._sessions.get(sid) def logout(self, sid: str) -> None: with self._lock: self._sessions.pop(sid, None)