| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103 |
- """Server-side session ledger with opaque rotating refresh credentials."""
- from __future__ import annotations
- import hashlib
- import secrets
- from collections.abc import Callable
- from dataclasses import dataclass
- from datetime import UTC, datetime, timedelta
- from typing import Any
- from app.core.common.identifiers import new_governance_uid
- from app.core.system.enterprise_identity import IdentityPolicyError
- from app.core.system.tokens import issue_access_token
- @dataclass(frozen=True)
- class SessionCredentials:
- session_uid: str
- access_token: str
- refresh_token: str
- class SessionManager:
- def __init__(self, repository: Any, *, secret: str, clock: Callable[[], datetime] | None = None,
- max_sessions: int = 5, refresh_lifetime: timedelta = timedelta(hours=8)) -> None:
- self.repository = repository
- self.secret = secret
- self.clock = clock or (lambda: datetime.now(UTC))
- self.max_sessions = max_sessions
- self.refresh_lifetime = refresh_lifetime
- @staticmethod
- def _digest(token: str) -> str:
- return hashlib.sha256(token.encode()).hexdigest()
- def _access(self, record: dict[str, Any]) -> str:
- return issue_access_token(user_id=record["user_uid"], roles=list(record["roles"]), secret=self.secret,
- now=self.clock(), session_uid=record["uid"], token_version=record["token_version"],
- identity_source=record["identity_source"])
- def create(self, *, subject: str, user_uid: str, roles: list[str], identity_source: str,
- token_version: int = 1, provider_uid: str | None = None,
- emergency_request_uid: str | None = None,
- emergency_expires_at: datetime | None = None, commit: bool = True) -> SessionCredentials:
- if identity_source not in {"oidc", "local", "emergency"}:
- raise IdentityPolicyError("unsupported identity source")
- if identity_source == "oidc" and not provider_uid:
- raise IdentityPolicyError("OIDC session requires provider binding")
- if identity_source == "emergency" and (not emergency_request_uid or not emergency_expires_at):
- raise IdentityPolicyError("emergency session requires request and expiry binding")
- now = self.clock()
- if identity_source == "emergency" and not self.repository.emergency_session_active(
- str(emergency_request_uid), user_uid, now):
- raise IdentityPolicyError("emergency account is no longer an active local administrator")
- refresh = secrets.token_urlsafe(48)
- record = {"uid": new_governance_uid(), "family_uid": new_governance_uid(), "subject": subject,
- "user_uid": user_uid, "roles": sorted(set(roles)), "identity_source": identity_source,
- "provider_uid": provider_uid, "emergency_request_uid": emergency_request_uid,
- "token_version": token_version, "refresh_hash": self._digest(refresh), "status": "active",
- "created_at": now, "last_seen_at": now,
- "expires_at": min(now + self.refresh_lifetime, emergency_expires_at) if emergency_expires_at else now + self.refresh_lifetime,
- "rotated_from_uid": None, "risk_reason": None}
- self.repository.create_session(record, max_sessions=self.max_sessions, commit=commit)
- return SessionCredentials(record["uid"], self._access(record), refresh)
- def refresh(self, refresh_token: str) -> SessionCredentials:
- record = self.repository.get_session_by_refresh(self._digest(refresh_token))
- if not record:
- raise IdentityPolicyError("unknown refresh credential")
- if record["identity_source"] == "emergency" and not self.repository.emergency_session_active(
- record["emergency_request_uid"], record["user_uid"], self.clock()):
- self.repository.revoke_family(record["family_uid"], reason="emergency_window_closed")
- raise IdentityPolicyError("emergency access window is not active")
- if record["status"] != "active":
- self.repository.revoke_family(record["family_uid"], reason="refresh_reuse")
- raise IdentityPolicyError("refresh reuse detected")
- if self.clock() >= record["expires_at"]:
- self.repository.update_session(record["uid"], status="revoked", revoke_reason="expired")
- raise IdentityPolicyError("refresh credential expired")
- refresh = secrets.token_urlsafe(48)
- next_expiry = self.clock() + self.refresh_lifetime
- if record["identity_source"] == "emergency":
- next_expiry = min(record["expires_at"], next_expiry)
- next_record = {**record, "uid": new_governance_uid(), "refresh_hash": self._digest(refresh),
- "status": "active", "created_at": self.clock(), "last_seen_at": self.clock(),
- "expires_at": next_expiry, "rotated_from_uid": record["uid"]}
- outcome = self.repository.rotate_refresh(self._digest(refresh_token), next_record)
- if outcome == "unknown":
- raise IdentityPolicyError("unknown refresh credential")
- if outcome == "reuse":
- raise IdentityPolicyError("refresh reuse detected")
- return SessionCredentials(next_record["uid"], self._access(next_record), refresh)
- def revoke(self, session_uid: str, *, reason: str = "logout") -> None:
- if self.repository.get_session(session_uid):
- self.repository.update_session(session_uid, status="revoked", revoke_reason=reason)
- def risk_revoke(self, session_uid: str, *, reason: str) -> None:
- self.repository.update_session(session_uid, status="revoked", revoke_reason="risk", risk_reason=reason)
- def revoke_subject(self, provider_uid: str | None, subject: str, *, reason: str) -> None:
- self.repository.revoke_subject_sessions(provider_uid, subject, reason=reason)
|