From 0ce11eac1a5e43f96056690bcdc1a732094c700d Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 22 Oct 2025 06:46:49 -0600 Subject: [PATCH] Add Engine authentication services and builders --- Data/Engine/CURRENT_STAGE.md | 2 +- Data/Engine/README.md | 11 + Data/Engine/builders/__init__.py | 20 +- Data/Engine/builders/device_auth.py | 165 ++++++++++++ Data/Engine/builders/device_enrollment.py | 100 ++++++++ Data/Engine/services/__init__.py | 18 +- Data/Engine/services/auth/__init__.py | 20 ++ .../services/auth/device_auth_service.py | 237 ++++++++++++++++++ Data/Engine/services/auth/token_service.py | 190 ++++++++++++++ 9 files changed, 760 insertions(+), 3 deletions(-) create mode 100644 Data/Engine/builders/device_auth.py create mode 100644 Data/Engine/builders/device_enrollment.py create mode 100644 Data/Engine/services/auth/__init__.py create mode 100644 Data/Engine/services/auth/device_auth_service.py create mode 100644 Data/Engine/services/auth/token_service.py diff --git a/Data/Engine/CURRENT_STAGE.md b/Data/Engine/CURRENT_STAGE.md index 40f156e..77bfce7 100644 --- a/Data/Engine/CURRENT_STAGE.md +++ b/Data/Engine/CURRENT_STAGE.md @@ -26,7 +26,7 @@ - 5.2 Map legacy error codes/enums into domain exceptions or enums in the same modules. - 5.3 Commit after unit tests (or doctests) validate dataclass invariants. -6. Port authentication services +[COMPLETED] 6. Port authentication services - 6.1 Copy `DeviceAuthManager` logic into `services/auth/device_auth_service.py`, refactoring to use new repositories and domain types. - 6.2 Create `builders/device_auth.py` to assemble `DeviceAuthContext` from headers/DPoP proof. - 6.3 Mirror refresh token issuance into `services/auth/token_service.py`; use `builders/device_enrollment.py` for payload assembly. diff --git a/Data/Engine/README.md b/Data/Engine/README.md index d14b241..80d6821 100644 --- a/Data/Engine/README.md +++ b/Data/Engine/README.md @@ -33,3 +33,14 @@ As migration continues, services, repositories, interfaces, and integrations wil ## Interface scaffolding The Engine currently exposes placeholder HTTP blueprints under `Data/Engine/interfaces/http/` (agents, enrollment, tokens, admin, and health) so that future commits can drop in real routes without reshaping the bootstrap wiring. WebSocket namespaces follow the same pattern in `Data/Engine/interfaces/ws/`, with feature-oriented modules (e.g., `agents`, `job_management`) registered by `bootstrapper.bootstrap()` when Socket.IO is available. These stubs intentionally contain no business logic yet—they merely ensure the application factory exercises the full wiring path. + +## Authentication services + +Step 6 introduces the first real Engine services: + +- `Data/Engine/builders/device_auth.py` normalizes headers for access-token authentication and token refresh payloads. +- `Data/Engine/builders/device_enrollment.py` prepares enrollment payloads and nonce proof challenges for future migration steps. +- `Data/Engine/services/auth/device_auth_service.py` ports the legacy `DeviceAuthManager` into a repository-driven service that emits `DeviceAuthContext` instances from the new domain layer. +- `Data/Engine/services/auth/token_service.py` issues refreshed access tokens while enforcing DPoP bindings and repository lookups. + +Interfaces will begin consuming these services once the repository adapters land in the next milestone. diff --git a/Data/Engine/builders/__init__.py b/Data/Engine/builders/__init__.py index 2608419..ab8b179 100644 --- a/Data/Engine/builders/__init__.py +++ b/Data/Engine/builders/__init__.py @@ -2,4 +2,22 @@ from __future__ import annotations -__all__: list[str] = [] +from .device_auth import ( + DeviceAuthRequest, + DeviceAuthRequestBuilder, + RefreshTokenRequest, + RefreshTokenRequestBuilder, +) +from .device_enrollment import ( + EnrollmentRequestBuilder, + ProofChallengeBuilder, +) + +__all__ = [ + "DeviceAuthRequest", + "DeviceAuthRequestBuilder", + "RefreshTokenRequest", + "RefreshTokenRequestBuilder", + "EnrollmentRequestBuilder", + "ProofChallengeBuilder", +] diff --git a/Data/Engine/builders/device_auth.py b/Data/Engine/builders/device_auth.py new file mode 100644 index 0000000..abc09e8 --- /dev/null +++ b/Data/Engine/builders/device_auth.py @@ -0,0 +1,165 @@ +"""Builders for device authentication and token refresh inputs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from Data.Engine.domain.device_auth import ( + DeviceAuthErrorCode, + DeviceAuthFailure, + DeviceGuid, + sanitize_service_context, +) + +__all__ = [ + "DeviceAuthRequest", + "DeviceAuthRequestBuilder", + "RefreshTokenRequest", + "RefreshTokenRequestBuilder", +] + + +@dataclass(frozen=True, slots=True) +class DeviceAuthRequest: + """Normalized authentication inputs derived from an HTTP request.""" + + access_token: str + http_method: str + htu: str + service_context: Optional[str] + dpop_proof: Optional[str] + + +class DeviceAuthRequestBuilder: + """Validate and normalize HTTP headers for device authentication.""" + + _authorization: Optional[str] + _http_method: Optional[str] + _htu: Optional[str] + _service_context: Optional[str] + _dpop_proof: Optional[str] + + def __init__(self) -> None: + self._authorization = None + self._http_method = None + self._htu = None + self._service_context = None + self._dpop_proof = None + + def with_authorization(self, header_value: Optional[str]) -> "DeviceAuthRequestBuilder": + if header_value is None: + self._authorization = None + else: + self._authorization = header_value.strip() + return self + + def with_http_method(self, method: Optional[str]) -> "DeviceAuthRequestBuilder": + self._http_method = (method or "").strip().upper() + return self + + def with_htu(self, url: Optional[str]) -> "DeviceAuthRequestBuilder": + self._htu = (url or "").strip() + return self + + def with_service_context(self, header_value: Optional[str]) -> "DeviceAuthRequestBuilder": + self._service_context = sanitize_service_context(header_value) + return self + + def with_dpop_proof(self, proof: Optional[str]) -> "DeviceAuthRequestBuilder": + self._dpop_proof = (proof or "").strip() or None + return self + + def build(self) -> DeviceAuthRequest: + token = self._parse_authorization(self._authorization) + method = (self._http_method or "").strip().upper() + if not method: + raise DeviceAuthFailure(DeviceAuthErrorCode.INVALID_TOKEN, detail="missing HTTP method") + url = (self._htu or "").strip() + if not url: + raise DeviceAuthFailure(DeviceAuthErrorCode.INVALID_TOKEN, detail="missing request URL") + return DeviceAuthRequest( + access_token=token, + http_method=method, + htu=url, + service_context=self._service_context, + dpop_proof=self._dpop_proof, + ) + + @staticmethod + def _parse_authorization(header_value: Optional[str]) -> str: + header = (header_value or "").strip() + if not header: + raise DeviceAuthFailure(DeviceAuthErrorCode.MISSING_AUTHORIZATION) + prefix = "Bearer " + if not header.startswith(prefix): + raise DeviceAuthFailure(DeviceAuthErrorCode.MISSING_AUTHORIZATION) + token = header[len(prefix) :].strip() + if not token: + raise DeviceAuthFailure(DeviceAuthErrorCode.MISSING_AUTHORIZATION) + return token + + +@dataclass(frozen=True, slots=True) +class RefreshTokenRequest: + """Validated refresh token payload supplied by an agent.""" + + guid: DeviceGuid + refresh_token: str + http_method: str + htu: str + dpop_proof: Optional[str] + + +class RefreshTokenRequestBuilder: + """Helper to normalize refresh token JSON payloads.""" + + _guid: Optional[str] + _refresh_token: Optional[str] + _http_method: Optional[str] + _htu: Optional[str] + _dpop_proof: Optional[str] + + def __init__(self) -> None: + self._guid = None + self._refresh_token = None + self._http_method = None + self._htu = None + self._dpop_proof = None + + def with_payload(self, payload: Optional[dict[str, object]]) -> "RefreshTokenRequestBuilder": + payload = payload or {} + self._guid = str(payload.get("guid") or "").strip() + self._refresh_token = str(payload.get("refresh_token") or "").strip() + return self + + def with_http_method(self, method: Optional[str]) -> "RefreshTokenRequestBuilder": + self._http_method = (method or "").strip().upper() + return self + + def with_htu(self, url: Optional[str]) -> "RefreshTokenRequestBuilder": + self._htu = (url or "").strip() + return self + + def with_dpop_proof(self, proof: Optional[str]) -> "RefreshTokenRequestBuilder": + self._dpop_proof = (proof or "").strip() or None + return self + + def build(self) -> RefreshTokenRequest: + if not self._guid: + raise DeviceAuthFailure(DeviceAuthErrorCode.INVALID_CLAIMS, detail="missing guid") + if not self._refresh_token: + raise DeviceAuthFailure(DeviceAuthErrorCode.INVALID_CLAIMS, detail="missing refresh token") + method = (self._http_method or "").strip().upper() + if not method: + raise DeviceAuthFailure(DeviceAuthErrorCode.INVALID_TOKEN, detail="missing HTTP method") + url = (self._htu or "").strip() + if not url: + raise DeviceAuthFailure(DeviceAuthErrorCode.INVALID_TOKEN, detail="missing request URL") + return RefreshTokenRequest( + guid=DeviceGuid(self._guid), + refresh_token=self._refresh_token, + http_method=method, + htu=url, + dpop_proof=self._dpop_proof, + ) diff --git a/Data/Engine/builders/device_enrollment.py b/Data/Engine/builders/device_enrollment.py new file mode 100644 index 0000000..0bb8fa9 --- /dev/null +++ b/Data/Engine/builders/device_enrollment.py @@ -0,0 +1,100 @@ +"""Builder utilities for device enrollment payloads.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from Data.Engine.domain.device_auth import DeviceFingerprint +from Data.Engine.domain.device_enrollment import EnrollmentRequest, ProofChallenge + +__all__ = [ + "EnrollmentRequestBuilder", + "ProofChallengeBuilder", +] + + +@dataclass(frozen=True, slots=True) +class _EnrollmentPayload: + hostname: str + enrollment_code: str + fingerprint: str + client_nonce: bytes + server_nonce: bytes + + +class EnrollmentRequestBuilder: + """Normalize agent enrollment JSON payloads into domain objects.""" + + def __init__(self) -> None: + self._payload: Optional[_EnrollmentPayload] = None + + def with_payload(self, payload: Optional[dict[str, object]]) -> "EnrollmentRequestBuilder": + payload = payload or {} + hostname = str(payload.get("hostname") or "").strip() + enrollment_code = str(payload.get("enrollment_code") or "").strip() + fingerprint = str(payload.get("fingerprint") or "").strip() + client_nonce = self._coerce_bytes(payload.get("client_nonce")) + server_nonce = self._coerce_bytes(payload.get("server_nonce")) + self._payload = _EnrollmentPayload( + hostname=hostname, + enrollment_code=enrollment_code, + fingerprint=fingerprint, + client_nonce=client_nonce, + server_nonce=server_nonce, + ) + return self + + def build(self) -> EnrollmentRequest: + if not self._payload: + raise ValueError("payload has not been provided") + return EnrollmentRequest.from_payload( + hostname=self._payload.hostname, + enrollment_code=self._payload.enrollment_code, + fingerprint=self._payload.fingerprint, + client_nonce=self._payload.client_nonce, + server_nonce=self._payload.server_nonce, + ) + + @staticmethod + def _coerce_bytes(value: object) -> bytes: + if isinstance(value, (bytes, bytearray)): + return bytes(value) + if isinstance(value, str): + return value.encode("utf-8") + raise ValueError("nonce values must be bytes or base strings") + + +class ProofChallengeBuilder: + """Construct proof challenges during enrollment approval.""" + + def __init__(self) -> None: + self._server_nonce: Optional[bytes] = None + self._client_nonce: Optional[bytes] = None + self._fingerprint: Optional[DeviceFingerprint] = None + + def with_server_nonce(self, nonce: Optional[bytes]) -> "ProofChallengeBuilder": + self._server_nonce = bytes(nonce or b"") + return self + + def with_client_nonce(self, nonce: Optional[bytes]) -> "ProofChallengeBuilder": + self._client_nonce = bytes(nonce or b"") + return self + + def with_fingerprint(self, fingerprint: Optional[str]) -> "ProofChallengeBuilder": + if fingerprint: + self._fingerprint = DeviceFingerprint(fingerprint) + else: + self._fingerprint = None + return self + + def build(self) -> ProofChallenge: + if self._server_nonce is None or self._client_nonce is None: + raise ValueError("both server and client nonces are required") + if not self._fingerprint: + raise ValueError("fingerprint is required") + return ProofChallenge( + client_nonce=self._client_nonce, + server_nonce=self._server_nonce, + fingerprint=self._fingerprint, + ) diff --git a/Data/Engine/services/__init__.py b/Data/Engine/services/__init__.py index 1b31cb3..0f932d1 100644 --- a/Data/Engine/services/__init__.py +++ b/Data/Engine/services/__init__.py @@ -2,4 +2,20 @@ from __future__ import annotations -__all__: list[str] = [] +from .auth import ( + DeviceAuthService, + DeviceRecord, + RefreshTokenRecord, + TokenRefreshError, + TokenRefreshErrorCode, + TokenService, +) + +__all__ = [ + "DeviceAuthService", + "DeviceRecord", + "RefreshTokenRecord", + "TokenRefreshError", + "TokenRefreshErrorCode", + "TokenService", +] diff --git a/Data/Engine/services/auth/__init__.py b/Data/Engine/services/auth/__init__.py new file mode 100644 index 0000000..a4efad1 --- /dev/null +++ b/Data/Engine/services/auth/__init__.py @@ -0,0 +1,20 @@ +"""Authentication services for the Borealis Engine.""" + +from __future__ import annotations + +from .device_auth_service import DeviceAuthService, DeviceRecord +from .token_service import ( + RefreshTokenRecord, + TokenRefreshError, + TokenRefreshErrorCode, + TokenService, +) + +__all__ = [ + "DeviceAuthService", + "DeviceRecord", + "RefreshTokenRecord", + "TokenRefreshError", + "TokenRefreshErrorCode", + "TokenService", +] diff --git a/Data/Engine/services/auth/device_auth_service.py b/Data/Engine/services/auth/device_auth_service.py new file mode 100644 index 0000000..0bc67f4 --- /dev/null +++ b/Data/Engine/services/auth/device_auth_service.py @@ -0,0 +1,237 @@ +"""Device authentication service copied from the legacy server stack.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Optional, Protocol +import logging + +from Data.Engine.builders.device_auth import DeviceAuthRequest +from Data.Engine.domain.device_auth import ( + AccessTokenClaims, + DeviceAuthContext, + DeviceAuthErrorCode, + DeviceAuthFailure, + DeviceFingerprint, + DeviceGuid, + DeviceIdentity, + DeviceStatus, +) + +__all__ = [ + "DeviceAuthService", + "DeviceRecord", + "DPoPValidator", + "DPoPVerificationError", + "DPoPReplayError", + "RateLimiter", + "RateLimitDecision", + "DeviceRepository", +] + + +class RateLimitDecision(Protocol): + allowed: bool + retry_after: Optional[float] + + +class RateLimiter(Protocol): + def check(self, key: str, max_requests: int, window_seconds: float) -> RateLimitDecision: # pragma: no cover - protocol + ... + + +class JWTDecoder(Protocol): + def decode(self, token: str) -> Mapping[str, object]: # pragma: no cover - protocol + ... + + +class DPoPValidator(Protocol): + def verify( + self, + method: str, + htu: str, + proof: str, + access_token: Optional[str] = None, + ) -> str: # pragma: no cover - protocol + ... + + +class DPoPVerificationError(Exception): + """Raised when a DPoP proof fails validation.""" + + +class DPoPReplayError(DPoPVerificationError): + """Raised when a DPoP proof is replayed.""" + + +@dataclass(frozen=True, slots=True) +class DeviceRecord: + """Snapshot of a device record required for authentication.""" + + identity: DeviceIdentity + token_version: int + status: DeviceStatus + + +class DeviceRepository(Protocol): + """Port that exposes the minimal device persistence operations.""" + + def fetch_by_guid(self, guid: DeviceGuid) -> Optional[DeviceRecord]: # pragma: no cover - protocol + ... + + def recover_missing( + self, + guid: DeviceGuid, + fingerprint: DeviceFingerprint, + token_version: int, + service_context: Optional[str], + ) -> Optional[DeviceRecord]: # pragma: no cover - protocol + ... + + +class DeviceAuthService: + """Authenticate devices using access tokens, repositories, and DPoP proofs.""" + + def __init__( + self, + *, + device_repository: DeviceRepository, + jwt_service: JWTDecoder, + logger: Optional[logging.Logger] = None, + rate_limiter: Optional[RateLimiter] = None, + dpop_validator: Optional[DPoPValidator] = None, + ) -> None: + self._repository = device_repository + self._jwt = jwt_service + self._log = logger or logging.getLogger("borealis.engine.auth") + self._rate_limiter = rate_limiter + self._dpop_validator = dpop_validator + + def authenticate(self, request: DeviceAuthRequest, *, path: str) -> DeviceAuthContext: + """Authenticate an access token and return the resulting context.""" + + claims = self._decode_claims(request.access_token) + rate_limit_key = f"fp:{claims.fingerprint.value}" + if self._rate_limiter is not None: + decision = self._rate_limiter.check(rate_limit_key, 60, 60.0) + if not decision.allowed: + raise DeviceAuthFailure( + DeviceAuthErrorCode.RATE_LIMITED, + http_status=429, + retry_after=decision.retry_after, + ) + + record = self._repository.fetch_by_guid(claims.guid) + if record is None: + record = self._repository.recover_missing( + claims.guid, + claims.fingerprint, + claims.token_version, + request.service_context, + ) + + if record is None: + raise DeviceAuthFailure( + DeviceAuthErrorCode.DEVICE_NOT_FOUND, + http_status=403, + ) + + self._validate_identity(record, claims) + + dpop_jkt = self._validate_dpop(request, record, claims) + + context = DeviceAuthContext( + identity=record.identity, + access_token=request.access_token, + claims=claims, + status=record.status, + service_context=request.service_context, + dpop_jkt=dpop_jkt, + ) + + if context.is_quarantined: + self._log.warning( + "device %s is quarantined; limited access for %s", + record.identity.guid, + path, + ) + + return context + + def _decode_claims(self, token: str) -> AccessTokenClaims: + try: + raw_claims = self._jwt.decode(token) + except Exception as exc: # pragma: no cover - defensive fallback + if self._is_expired_signature(exc): + raise DeviceAuthFailure(DeviceAuthErrorCode.TOKEN_EXPIRED) from exc + raise DeviceAuthFailure(DeviceAuthErrorCode.INVALID_TOKEN) from exc + + try: + return AccessTokenClaims.from_mapping(raw_claims) + except Exception as exc: + raise DeviceAuthFailure(DeviceAuthErrorCode.INVALID_CLAIMS) from exc + + @staticmethod + def _is_expired_signature(exc: Exception) -> bool: + name = exc.__class__.__name__ + return name == "ExpiredSignatureError" + + def _validate_identity( + self, + record: DeviceRecord, + claims: AccessTokenClaims, + ) -> None: + if record.identity.guid.value != claims.guid.value: + raise DeviceAuthFailure( + DeviceAuthErrorCode.DEVICE_GUID_MISMATCH, + http_status=403, + ) + + if record.identity.fingerprint.value: + if record.identity.fingerprint.value != claims.fingerprint.value: + raise DeviceAuthFailure( + DeviceAuthErrorCode.FINGERPRINT_MISMATCH, + http_status=403, + ) + + if record.token_version > claims.token_version: + raise DeviceAuthFailure(DeviceAuthErrorCode.TOKEN_VERSION_REVOKED) + + if not record.status.allows_access: + raise DeviceAuthFailure( + DeviceAuthErrorCode.DEVICE_REVOKED, + http_status=403, + ) + + def _validate_dpop( + self, + request: DeviceAuthRequest, + record: DeviceRecord, + claims: AccessTokenClaims, + ) -> Optional[str]: + if not request.dpop_proof: + return None + + if self._dpop_validator is None: + raise DeviceAuthFailure( + DeviceAuthErrorCode.DPOP_NOT_SUPPORTED, + http_status=400, + ) + + try: + return self._dpop_validator.verify( + request.http_method, + request.htu, + request.dpop_proof, + request.access_token, + ) + except DPoPReplayError as exc: + raise DeviceAuthFailure( + DeviceAuthErrorCode.DPOP_REPLAYED, + http_status=400, + ) from exc + except DPoPVerificationError as exc: + raise DeviceAuthFailure( + DeviceAuthErrorCode.DPOP_INVALID, + http_status=400, + ) from exc diff --git a/Data/Engine/services/auth/token_service.py b/Data/Engine/services/auth/token_service.py new file mode 100644 index 0000000..f49cd8c --- /dev/null +++ b/Data/Engine/services/auth/token_service.py @@ -0,0 +1,190 @@ +"""Token refresh service extracted from the legacy blueprint.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional, Protocol +import hashlib +import logging + +from Data.Engine.builders.device_auth import RefreshTokenRequest +from Data.Engine.domain.device_auth import DeviceGuid + +from .device_auth_service import ( + DeviceRecord, + DeviceRepository, + DPoPReplayError, + DPoPVerificationError, + DPoPValidator, +) + +__all__ = ["RefreshTokenRecord", "TokenService", "TokenRefreshError", "TokenRefreshErrorCode"] + + +class JWTIssuer(Protocol): + def issue_access_token(self, guid: str, fingerprint: str, token_version: int) -> str: # pragma: no cover - protocol + ... + + +class TokenRefreshErrorCode(str): + INVALID_REFRESH_TOKEN = "invalid_refresh_token" + REFRESH_TOKEN_REVOKED = "refresh_token_revoked" + REFRESH_TOKEN_EXPIRED = "refresh_token_expired" + DEVICE_NOT_FOUND = "device_not_found" + DEVICE_REVOKED = "device_revoked" + DPOP_REPLAYED = "dpop_replayed" + DPOP_INVALID = "dpop_invalid" + + +class TokenRefreshError(Exception): + def __init__(self, code: str, *, http_status: int = 400) -> None: + self.code = code + self.http_status = http_status + super().__init__(code) + + def to_dict(self) -> dict[str, str]: + return {"error": self.code} + + +@dataclass(frozen=True, slots=True) +class RefreshTokenRecord: + record_id: int + guid: DeviceGuid + token_hash: str + dpop_jkt: Optional[str] + created_at: datetime + expires_at: Optional[datetime] + revoked_at: Optional[datetime] + + @classmethod + def from_row( + cls, + *, + record_id: int, + guid: DeviceGuid, + token_hash: str, + dpop_jkt: Optional[str], + created_at: datetime, + expires_at: Optional[datetime], + revoked_at: Optional[datetime], + ) -> "RefreshTokenRecord": + return cls( + record_id=record_id, + guid=guid, + token_hash=token_hash, + dpop_jkt=dpop_jkt, + created_at=created_at, + expires_at=expires_at, + revoked_at=revoked_at, + ) + + +class RefreshTokenRepository(Protocol): + def fetch(self, guid: DeviceGuid, token_hash: str) -> Optional[RefreshTokenRecord]: # pragma: no cover - protocol + ... + + def clear_dpop_binding(self, record_id: int) -> None: # pragma: no cover - protocol + ... + + def touch(self, record_id: int, *, last_used_at: datetime, dpop_jkt: Optional[str]) -> None: # pragma: no cover - protocol + ... + + +@dataclass(frozen=True, slots=True) +class AccessTokenResponse: + access_token: str + expires_in: int + token_type: str + + +class TokenService: + def __init__( + self, + *, + refresh_token_repository: RefreshTokenRepository, + device_repository: DeviceRepository, + jwt_service: JWTIssuer, + dpop_validator: Optional[DPoPValidator] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + self._refresh_tokens = refresh_token_repository + self._devices = device_repository + self._jwt = jwt_service + self._dpop_validator = dpop_validator + self._log = logger or logging.getLogger("borealis.engine.auth") + + def refresh_access_token( + self, + request: RefreshTokenRequest, + ) -> AccessTokenResponse: + record = self._refresh_tokens.fetch( + request.guid, + self._hash_token(request.refresh_token), + ) + if record is None: + raise TokenRefreshError(TokenRefreshErrorCode.INVALID_REFRESH_TOKEN, http_status=401) + + if record.guid.value != request.guid.value: + raise TokenRefreshError(TokenRefreshErrorCode.INVALID_REFRESH_TOKEN, http_status=401) + + if record.revoked_at is not None: + raise TokenRefreshError(TokenRefreshErrorCode.REFRESH_TOKEN_REVOKED, http_status=401) + + if record.expires_at is not None and record.expires_at <= self._now(): + raise TokenRefreshError(TokenRefreshErrorCode.REFRESH_TOKEN_EXPIRED, http_status=401) + + device = self._devices.fetch_by_guid(request.guid) + if device is None: + raise TokenRefreshError(TokenRefreshErrorCode.DEVICE_NOT_FOUND, http_status=404) + + if not device.status.allows_access: + raise TokenRefreshError(TokenRefreshErrorCode.DEVICE_REVOKED, http_status=403) + + dpop_jkt = record.dpop_jkt or "" + if request.dpop_proof: + if self._dpop_validator is None: + raise TokenRefreshError(TokenRefreshErrorCode.DPOP_INVALID) + try: + dpop_jkt = self._dpop_validator.verify( + request.http_method, + request.htu, + request.dpop_proof, + None, + ) + except DPoPReplayError as exc: + raise TokenRefreshError(TokenRefreshErrorCode.DPOP_REPLAYED) from exc + except DPoPVerificationError as exc: + raise TokenRefreshError(TokenRefreshErrorCode.DPOP_INVALID) from exc + elif record.dpop_jkt: + self._log.warning( + "Clearing stored DPoP binding for guid=%s due to missing proof", + request.guid.value, + ) + self._refresh_tokens.clear_dpop_binding(record.record_id) + + access_token = self._jwt.issue_access_token( + request.guid.value, + device.identity.fingerprint.value, + max(device.token_version, 1), + ) + + self._refresh_tokens.touch( + record.record_id, + last_used_at=self._now(), + dpop_jkt=dpop_jkt or None, + ) + + return AccessTokenResponse( + access_token=access_token, + expires_in=900, + token_type="Bearer", + ) + + @staticmethod + def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + @staticmethod + def _now() -> datetime: + return datetime.now(tz=timezone.utc)