Add Engine authentication services and builders

This commit is contained in:
2025-10-22 06:46:49 -06:00
parent c931cd9060
commit 0ce11eac1a
9 changed files with 760 additions and 3 deletions

View File

@@ -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",
]

View 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,
)

View 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,
)