from dataclasses import dataclass from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Protocol from uuid import uuid4 import jwt from app.core.config import Settings from app.core.contracts import RequestContext from app.core.errors import UnauthorizedAgentError class RevocationStore(Protocol): def is_revoked(self, jti: str) -> bool: ... @dataclass(frozen=True) class EmptyRevocationStore: def is_revoked(self, jti: str) -> bool: return False class VisitorTokenIssuer: """Issues short-lived anonymous tokens for public customer-service access.""" def __init__(self, settings: Settings) -> None: self._settings = settings self._private_key = self._load_private_key() def _load_private_key(self) -> str: path = Path(self._settings.jwt_private_key_path) if not path.is_absolute(): path = Path.cwd() / path try: return path.read_text(encoding="utf-8") except OSError as exc: raise RuntimeError(f"JWT private key cannot be read: {path}") from exc def issue(self) -> tuple[str, datetime]: now = datetime.now(UTC) expires_at = now + timedelta(seconds=self._settings.visitor_token_ttl_seconds) subject = str(uuid4().int % 9_000_000_000_000_000_000 + 1) token = jwt.encode( {"sub": subject, "iss": self._settings.jwt_issuer, "aud": self._settings.jwt_audience, "iat": now, "nbf": now, "exp": expires_at, "jti": str(uuid4()), "visitor": True}, self._private_key, algorithm=self._settings.jwt_algorithm, ) return token, expires_at class JwtAuthenticator: def __init__(self, settings: Settings, revocation_store: RevocationStore | None = None) -> None: self._settings = settings self._revocation_store = revocation_store or EmptyRevocationStore() self._public_key = self._load_public_key() def _load_public_key(self) -> str: path = Path(self._settings.jwt_public_key_path) if not path.is_absolute(): path = Path.cwd() / path try: return path.read_text(encoding="utf-8") except OSError as exc: raise RuntimeError(f"JWT public key cannot be read: {path}") from exc def authenticate(self, token: str) -> RequestContext: if not token: raise UnauthorizedAgentError("missing bearer token") try: claims = jwt.decode( token, self._public_key, algorithms=[self._settings.jwt_algorithm], issuer=self._settings.jwt_issuer, audience=self._settings.jwt_audience, leeway=self._settings.jwt_clock_skew_seconds, options={"require": ["sub", "iss", "aud", "exp", "nbf", "jti"]}, ) except jwt.PyJWTError as exc: raise UnauthorizedAgentError("invalid bearer token") from exc jti = str(claims["jti"]) if self._revocation_store.is_revoked(jti): raise UnauthorizedAgentError("revoked bearer token") subject = claims["sub"] if (not isinstance(subject, str) or not subject.isascii() or not subject.isdecimal() or len(subject) > 20 or not 0 < int(subject) <= 18446744073709551615): raise UnauthorizedAgentError("invalid subject") if claims.get("visitor") is True: return RequestContext( user_id=str(subject), trace_id=str(uuid4()), roles=("visitor",), permissions=("agent:run",), data_scope="public", ) return RequestContext(user_id=str(claims["sub"]), trace_id=str(uuid4()))