mirror of
				https://github.com/bunny-lab-io/Borealis.git
				synced 2025-10-26 15:41:58 -06:00 
			
		
		
		
	Add Engine authentication services and builders
This commit is contained in:
		| @@ -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. | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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", | ||||
| ] | ||||
|   | ||||
							
								
								
									
										165
									
								
								Data/Engine/builders/device_auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								Data/Engine/builders/device_auth.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
|         ) | ||||
							
								
								
									
										100
									
								
								Data/Engine/builders/device_enrollment.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								Data/Engine/builders/device_enrollment.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
|         ) | ||||
| @@ -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", | ||||
| ] | ||||
|   | ||||
							
								
								
									
										20
									
								
								Data/Engine/services/auth/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								Data/Engine/services/auth/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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", | ||||
| ] | ||||
							
								
								
									
										237
									
								
								Data/Engine/services/auth/device_auth_service.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										237
									
								
								Data/Engine/services/auth/device_auth_service.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										190
									
								
								Data/Engine/services/auth/token_service.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								Data/Engine/services/auth/token_service.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
		Reference in New Issue
	
	Block a user