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

@@ -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.

View File

@@ -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
Step6 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.

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

View File

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

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

View 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

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