2026-09-10 22:05:17 +02:00
|
|
|
"""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)
|
2026-09-11 00:31:03 +02:00
|
|
|
roles: tuple[str, ...] = ()
|
2026-09-10 22:05:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
2026-09-11 00:31:03 +02:00
|
|
|
tokens["access_token"], roles=tuple(access["roles"]))
|
2026-09-10 22:05:17 +02:00
|
|
|
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)
|