mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 17: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