98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
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()))
|