Source code for litestar_security.accounts._sessions

"""Native session records, binding proofs, and the session authentication backend.

This module owns the browser-facing half of local authentication. It reads and
writes Litestar's native session, so it never imports the refresh-token modules;
callers that need both profiles compose them at the configuration layer.
"""

from collections.abc import Callable, Mapping, MutableMapping, Sequence
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from hmac import compare_digest
from hmac import digest as hmac_digest
from logging import getLogger
from secrets import token_bytes
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Generic, Literal, Protocol, TypeVar, cast, runtime_checkable

from litestar.connection import ASGIConnection
from litestar.datastructures import Cookie
from litestar.enums import ScopeType
from litestar.exceptions import ImproperlyConfiguredException

from litestar_security.accounts._internal import (
    DIGEST_BYTES,
    LOOKUP_BYTES,
    MINIMUM_PEPPER_BYTES,
    SECRET_BYTES,
    SECRET_CHARACTERS,
    SESSION_ID_BYTES,
    aware_utc_time,
    decode_random,
    encode_random,
    strict_text,
    valid_identifier,
    valid_security_epoch,
)
from litestar_security.accounts._operations import (
    OUTCOME_CREATED,
    OUTCOME_REVOKED,
    SESSION_LOGOUT,
    SESSION_REBIND,
    SESSION_REVOKE,
)
from litestar_security.accounts._records import SecurityEvent
from litestar_security.authentication import (
    Authenticated,
    InvalidCredentials,
    NoCredentials,
    PresentedCredential,
    VerificationUnavailable,
    queue_security_response_header,
)
from litestar_security.context import AuthenticationEvidence, Principal

if TYPE_CHECKING:
    from litestar.types import Scope

    from litestar_security.accounts._records import LocalAccountState

__all__ = (
    "CreateSessionCommand",
    "NativeSessionAuth",
    "NativeSessionStore",
    "ResolvedUserAuthSession",
    "SessionAuthentication",
    "SessionBindingConfig",
    "SessionBindingProof",
    "SessionRebindPlan",
    "SessionRegistry",
    "SessionSummary",
    "UserAuthSession",
    "UserAuthSessionResolver",
)


UserT = TypeVar("UserT")


_EMPTY_DISPLAY_METADATA: "Mapping[str, str]" = MappingProxyType({})


_SESSION_AUTHENTICATION_KEY = "_litestar_security"


_SESSION_PAYLOAD_VERSION = 3
_SESSION_PAYLOAD_ASSURANCE_VERSION = 2


_SESSION_BINDING_PREFIX = "sb_"


_SESSION_BINDING_DOMAIN = b"session-binding\x00"


_LOOKUP_CHARACTERS = 22


_DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 14


_DEFAULT_TOUCH_INTERVAL = timedelta(minutes=5)


_MAXIMUM_DISPLAY_METADATA = 32


_MAXIMUM_DISPLAY_METADATA_ITEM_BYTES = 256


_MAXIMUM_DISPLAY_METADATA_BYTES = 4_096


_ASCII_CONTROL_LIMIT = 32


_LOGGER = getLogger(__name__)


[docs] @dataclass(frozen=True, slots=True) class SessionBindingConfig: """Independent proof-of-possession cookie configuration.""" pepper: bytes = field(repr=False) cookie_name: str = "__Host-litestar-security-binding" secure: bool = True same_site: Literal["lax", "strict", "none"] = "lax" path: str = "/" domain: str | None = None max_age: int = _DEFAULT_SESSION_MAX_AGE touch_interval: timedelta = _DEFAULT_TOUCH_INTERVAL preserve_session_keys: tuple[str, ...] = () allow_insecure: bool = False def __post_init__(self) -> None: """Reject configurations that cannot provide the planned binding boundary.""" if self.pepper.__class__ is not bytes or len(self.pepper) < MINIMUM_PEPPER_BYTES: msg = "Session binding pepper must contain at least 32 bytes" raise ImproperlyConfiguredException(detail=msg) _validate_binding_cookie_config(self) _validate_binding_lifetime_config(self) _validate_preserved_session_keys(self.preserve_session_keys)
[docs] @dataclass(frozen=True, slots=True) class SessionAuthentication: """Authentication state stored inside the native Litestar session.""" session_id: str binding_id: str account_id: str security_epoch: int authenticated_at: "datetime" expires_at: "datetime" assurance_expires_at: "datetime | None" = None methods: frozenset[str] = frozenset({"password"}) traits: frozenset[str] = frozenset({"session"}) amr: tuple[str, ...] = ("pwd",) def __post_init__(self) -> None: """Reject malformed or contradictory native authentication payloads.""" try: authenticated_at = aware_utc_time(self.authenticated_at) expires_at = aware_utc_time(self.expires_at) assurance_expires_at = ( aware_utc_time(self.assurance_expires_at) if self.assurance_expires_at is not None else None ) except (AttributeError, ValueError): msg = "Session authentication timestamps must be timezone-aware" raise ValueError(msg) from None if not valid_security_epoch(self.security_epoch): msg = "Session authentication security epoch is invalid" raise ValueError(msg) if ( not valid_identifier(self.session_id) or not valid_identifier(self.binding_id, prefix=_SESSION_BINDING_PREFIX) or not strict_text(self.account_id) or expires_at <= authenticated_at or (assurance_expires_at is not None and assurance_expires_at <= authenticated_at) ): msg = "Session authentication payload is invalid" raise ValueError(msg) try: evidence = AuthenticationEvidence( mechanism="local", slot="session", authenticated_at=authenticated_at, expires_at=min(assurance_expires_at, expires_at) if assurance_expires_at is not None else expires_at, methods=self.methods, traits=self.traits, amr=self.amr, ) except (AttributeError, TypeError, ValueError): msg = "Session authentication assurance is invalid" raise ValueError(msg) from None object.__setattr__(self, "authenticated_at", authenticated_at) object.__setattr__(self, "expires_at", expires_at) object.__setattr__(self, "assurance_expires_at", assurance_expires_at) object.__setattr__(self, "methods", evidence.methods) object.__setattr__(self, "traits", evidence.traits) object.__setattr__(self, "amr", evidence.amr)
[docs] @dataclass(frozen=True, slots=True) class SessionBindingProof: """Parsed binding lookup and domain-separated digest without the raw secret.""" binding_id: str digest: bytes = field(repr=False) def __post_init__(self) -> None: """Require canonical binding lookup and fixed-size digest.""" if ( not valid_identifier(self.binding_id, prefix=_SESSION_BINDING_PREFIX) or self.digest.__class__ is not bytes or len(self.digest) != DIGEST_BYTES ): msg = "Session binding proof is invalid" raise ValueError(msg)
[docs] @dataclass(frozen=True, slots=True) class UserAuthSession: """Application-owned authenticated-session registry projection.""" session_id: str binding_id: str binding_digest: bytes = field(repr=False) account_id: str security_epoch: int created_at: "datetime" authenticated_at: "datetime" last_seen_at: "datetime" expires_at: "datetime" display_metadata: "Mapping[str, str]" = field(default_factory=lambda: _EMPTY_DISPLAY_METADATA) def __post_init__(self) -> None: """Validate authoritative record state and freeze safe display metadata.""" try: created_at = aware_utc_time(self.created_at) authenticated_at = aware_utc_time(self.authenticated_at) last_seen_at = aware_utc_time(self.last_seen_at) expires_at = aware_utc_time(self.expires_at) except (AttributeError, ValueError): msg = "Session record timestamps must be timezone-aware" raise ValueError(msg) from None if not valid_security_epoch(self.security_epoch): msg = "Session record security epoch is invalid" raise ValueError(msg) if ( not valid_identifier(self.session_id) or not valid_identifier(self.binding_id, prefix=_SESSION_BINDING_PREFIX) or self.binding_digest.__class__ is not bytes or len(self.binding_digest) != DIGEST_BYTES or not strict_text(self.account_id) or not created_at <= last_seen_at < expires_at or authenticated_at > expires_at ): msg = "Session record is invalid" raise ValueError(msg) object.__setattr__(self, "created_at", created_at) object.__setattr__(self, "authenticated_at", authenticated_at) object.__setattr__(self, "last_seen_at", last_seen_at) object.__setattr__(self, "expires_at", expires_at) object.__setattr__(self, "display_metadata", _freeze_display_metadata(self.display_metadata))
[docs] @dataclass(frozen=True, slots=True) class CreateSessionCommand: """Candidate authenticated-session record for one atomic creation.""" session_id: str binding_id: str binding_digest: bytes = field(repr=False) account_id: str security_epoch: int created_at: "datetime" authenticated_at: "datetime" expires_at: "datetime" display_metadata: "Mapping[str, str]" = field(default_factory=lambda: _EMPTY_DISPLAY_METADATA) def __post_init__(self) -> None: """Validate atomic creation material and freeze safe display metadata.""" try: created_at = aware_utc_time(self.created_at) authenticated_at = aware_utc_time(self.authenticated_at) expires_at = aware_utc_time(self.expires_at) except (AttributeError, ValueError): msg = "Session creation timestamps must be timezone-aware" raise ValueError(msg) from None if not valid_security_epoch(self.security_epoch): msg = "Session creation security epoch is invalid" raise ValueError(msg) if ( not valid_identifier(self.session_id) or not valid_identifier(self.binding_id, prefix=_SESSION_BINDING_PREFIX) or self.binding_digest.__class__ is not bytes or len(self.binding_digest) != DIGEST_BYTES or not strict_text(self.account_id) or expires_at <= created_at or authenticated_at > expires_at ): msg = "Session creation command is invalid" raise ValueError(msg) object.__setattr__(self, "created_at", created_at) object.__setattr__(self, "authenticated_at", authenticated_at) object.__setattr__(self, "expires_at", expires_at) object.__setattr__(self, "display_metadata", _freeze_display_metadata(self.display_metadata))
[docs] @dataclass(frozen=True, slots=True) class SessionRebindPlan: """Reveal-once browser state prepared for an atomic password-session rebind.""" prior_session_id: str command: "CreateSessionCommand" binding_token: str = field(repr=False) authenticated_at: datetime def __post_init__(self) -> None: """Require one canonical prior identity and matching replacement material.""" authenticated_at = aware_utc_time(self.authenticated_at) if ( not valid_identifier(self.prior_session_id) or self.command.__class__ is not CreateSessionCommand or self.command.session_id == self.prior_session_id or self.binding_token.__class__ is not str or self.command.binding_id != self.binding_token.partition(".")[0] or self.command.created_at != authenticated_at or self.command.authenticated_at != authenticated_at ): msg = "Session rebind plan is invalid" raise ValueError(msg) object.__setattr__(self, "authenticated_at", authenticated_at)
[docs] @dataclass(frozen=True, slots=True) class SessionSummary: """Safe authenticated-session inventory projection.""" session_id: str current: bool created_at: "datetime" last_seen_at: "datetime" expires_at: "datetime" display_metadata: "Mapping[str, str]" = field(default_factory=lambda: _EMPTY_DISPLAY_METADATA) def __post_init__(self) -> None: """Validate safe listing state without accepting binding material.""" try: created_at = aware_utc_time(self.created_at) last_seen_at = aware_utc_time(self.last_seen_at) expires_at = aware_utc_time(self.expires_at) except (AttributeError, ValueError): msg = "Session summary timestamps must be timezone-aware" raise ValueError(msg) from None if ( not valid_identifier(self.session_id) or self.current.__class__ is not bool or not created_at <= last_seen_at < expires_at ): msg = "Session summary is invalid" raise ValueError(msg) object.__setattr__(self, "created_at", created_at) object.__setattr__(self, "last_seen_at", last_seen_at) object.__setattr__(self, "expires_at", expires_at) object.__setattr__(self, "display_metadata", _freeze_display_metadata(self.display_metadata))
[docs] @runtime_checkable class SessionRegistry(Protocol): """Atomic authenticated-session inventory and revocation boundary."""
[docs] async def create(self, command: CreateSessionCommand, *, event: "SecurityEvent") -> UserAuthSession: """Create a registry record with its durable event. Args: command: The session identifier, account binding, epoch, and lifetime to store. event: The audit event to commit with the record. Rejecting it must fail the creation. Returns: The stored record. """ ... # pragma: no cover
[docs] async def get(self, session_id: str) -> UserAuthSession | None: """Load one current session record. Args: session_id: The session to load. Returns: The record, or ``None`` when the session is absent, expired, or revoked. """ ... # pragma: no cover
[docs] async def list_for_account(self, account_id: str) -> "Sequence[UserAuthSession]": """List safe session metadata for one account. Args: account_id: The account whose sessions to list. Returns: The account's active session records, which may be empty. """ ... # pragma: no cover
[docs] async def touch(self, session_id: str, *, now: "datetime") -> UserAuthSession | None: """Apply the implementation's bounded last-seen write policy. Called on every authenticated request, so throttling the write is the implementation's decision rather than the caller's. Args: session_id: The session that was just used. now: The observation timestamp. Returns: The current record, or ``None`` when the session is no longer valid. """ ... # pragma: no cover
[docs] async def revoke_session_for_account(self, account_id: str, session_id: str, *, event: "SecurityEvent") -> bool: """Revoke one session only when atomically owned by the account. Check ownership inside this operation. A caller must not be able to revoke another account's session by naming its identifier. Args: account_id: The authenticated caller's account. session_id: The session to revoke. event: The audit event to commit with the revocation. Rejecting it must fail the revocation. Returns: ``True`` when the caller owned an active session that was revoked. """ ... # pragma: no cover
[docs] async def revoke_sessions_for_account(self, account_id: str, *, event: "SecurityEvent") -> int: """Revoke every authenticated session for an account. Args: account_id: The account whose sessions to revoke. event: The audit event to commit with the revocations. Rejecting it must fail them. Returns: The number of active sessions revoked. """ ... # pragma: no cover
[docs] async def revoke_other_sessions(self, account_id: str, session_id: str, *, event: "SecurityEvent") -> int: """Revoke all account sessions except the named current session. Args: account_id: The account whose sessions to revoke. session_id: The one session to keep, normally the caller's own. event: The audit event to commit with the revocations. Rejecting it must fail them. Returns: The number of other active sessions revoked. """ ... # pragma: no cover
[docs] async def rebind( self, prior_session_id: str, command: CreateSessionCommand, *, event: "SecurityEvent" ) -> UserAuthSession | None: """Revoke a prior record and create its replacement atomically. Both halves commit together. A window in which neither or both sessions are valid is what session fixation exploits. Args: prior_session_id: The session being replaced. command: The replacement session to create. event: The audit event to commit with the rebind. Rejecting it must fail the rebind. Returns: The replacement record, or ``None`` when the prior session was already gone. """ ... # pragma: no cover
[docs] @dataclass(frozen=True, slots=True) class ResolvedUserAuthSession(Generic[UserT]): """One consistent session and account read produced by an application store.""" session: UserAuthSession account: "LocalAccountState[UserT]"
[docs] @runtime_checkable class UserAuthSessionResolver(Protocol[UserT]): """Resolve the complete authoritative session state in one consistent read."""
[docs] async def resolve_user_auth_session( self, session_id: str, account_id: str, *, now: datetime ) -> "ResolvedUserAuthSession[UserT] | None": """Load a session and its account from one consistent snapshot. Implementations must bind both returned values to the requested identifiers and must not return stale account epoch or activation state. Args: session_id: Session identifier presented by the client. account_id: Account identifier embedded in the session payload. now: Authoritative UTC time for expiry-aware storage queries. Returns: The consistent session and account state, or ``None`` for invalid credentials. Raises: Exception: When authoritative storage is unavailable. """ ... # pragma: no cover
[docs] @runtime_checkable class NativeSessionStore(SessionRegistry, Protocol[UserT]): """Combined account, epoch, and session capabilities for native authentication."""
[docs] async def get_by_id(self, account_id: str) -> "LocalAccountState[UserT] | None": """Load one local account projection. Args: account_id: The account named by the session. Returns: The account projection, or ``None`` when the account no longer exists. """ ... # pragma: no cover
[docs] async def current_epoch(self, account_id: str) -> int | None: """Load the authoritative account security epoch. Args: account_id: The account whose epoch to read. Returns: The current epoch, or ``None`` when the account does not exist. """ ... # pragma: no cover
[docs] @dataclass(slots=True) class NativeSessionAuth(Generic[UserT]): """Native Litestar session mechanism and fixation-resistant lifecycle service.""" accounts: NativeSessionStore[UserT] = field(repr=False) binding: SessionBindingConfig = field(repr=False) resolver: UserAuthSessionResolver[UserT] | None = field(default=None, repr=False) clock: "Callable[[], datetime]" = field(default=lambda: datetime.now(timezone.utc), repr=False, compare=False) entropy: "Callable[[int], bytes]" = field(default=token_bytes, repr=False, compare=False) event_ids: "Callable[[], str]" = field(default=lambda: encode_random(token_bytes(16)), repr=False, compare=False) name: str = field(default="session", init=False) slot: str = field(default="session", init=False) participates_by_default: bool = field(default=True, init=False) def __post_init__(self) -> None: """Validate the combined account, epoch, registry, and customization ports.""" _validate_native_session_store(self.accounts) if self.resolver is not None and not callable(getattr(self.resolver, "resolve_user_auth_session", None)): msg = "Native session resolver must implement resolve_user_auth_session" raise ImproperlyConfiguredException(detail=msg) if self.binding.__class__ is not SessionBindingConfig: msg = "Native session binding must be SessionBindingConfig" raise ImproperlyConfiguredException(detail=msg) clock_value: object = self.clock entropy_value: object = self.entropy event_ids_value: object = self.event_ids if not callable(clock_value) or not callable(entropy_value) or not callable(event_ids_value): msg = "Native session customization hooks must be callable" raise ImproperlyConfiguredException(detail=msg)
[docs] def extract( self, connection: ASGIConnection[Any, Any, Any, Any] ) -> NoCredentials | PresentedCredential["_SessionCredential"] | InvalidCredentials: """Extract the native authentication payload and independent binding proof once. Args: connection: The incoming connection. Returns: The presented session credential, ``NoCredentials`` when the connection carries no session, or ``InvalidCredentials`` when what it carries is malformed. """ session = self._session_mapping(connection.scope) payload = session.get(_SESSION_AUTHENTICATION_KEY) if session is not None else None raw_binding = connection.cookies.get(self.binding.cookie_name) if payload is None and raw_binding is None: return NoCredentials() authentication = self._decode_authentication(payload) binding = self._binding_proof(raw_binding) if authentication is None or binding is None: self._clear_local_state(connection.scope) return InvalidCredentials() return PresentedCredential(_SessionCredential(authentication=authentication, binding=binding))
[docs] async def authenticate( self, credential: "_SessionCredential", connection: ASGIConnection[Any, Any, Any, Any] ) -> Authenticated["LocalAccountState[UserT]"] | InvalidCredentials | VerificationUnavailable: """Verify registry, binding, account, and exact epoch state. Args: credential: The session identifier and binding proof taken from the connection. connection: The incoming connection. Returns: The authenticated account, ``InvalidCredentials`` when any check fails, or ``VerificationUnavailable`` when a dependency failed. """ if credential.__class__ is not _SessionCredential: self._clear_local_state(connection.scope) return InvalidCredentials() authentication = credential.authentication try: now = aware_utc_time(self.clock()) if self.resolver is None: record = await self.accounts.get(authentication.session_id) account = await self.accounts.get_by_id(authentication.account_id) current_epoch = await self.accounts.current_epoch(authentication.account_id) else: resolved = await self.resolver.resolve_user_auth_session( authentication.session_id, authentication.account_id, now=now ) record = resolved.session if resolved is not None else None account = resolved.account if resolved is not None else None current_epoch = account.security_epoch if account is not None else None except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() if ( record is None or account is None or not self._valid_current_state( authentication, credential.binding, record, account=account, current_epoch=current_epoch, now=now ) ): self._clear_local_state(connection.scope) return InvalidCredentials() if now - record.last_seen_at >= self.binding.touch_interval: try: await self.accounts.touch(record.session_id, now=now) except Exception: # noqa: BLE001 - application-supplied code may raise anything; fail closed _LOGGER.error("Session last-seen update failed") # noqa: TRY400 - omit untrusted exception details return Authenticated( claims=account, evidence=AuthenticationEvidence( mechanism=self.name, slot=self.slot, authenticated_at=authentication.authenticated_at, expires_at=( min(authentication.assurance_expires_at, authentication.expires_at) if authentication.assurance_expires_at is not None else authentication.expires_at ), methods=authentication.methods, traits=authentication.traits, amr=authentication.amr, ), )
[docs] async def resolve(self, claims: "LocalAccountState[UserT]") -> Principal[UserT]: """Resolve an already validated local account without another store call. Args: claims: The account projection produced by authentication. Returns: The principal for the request. """ return Principal(id=claims.account_id, display_name=claims.display_name, user=claims.user)
[docs] async def establish( self, connection: ASGIConnection[Any, Any, Any, Any], account: "LocalAccountState[UserT]", *, evidence: AuthenticationEvidence | None = None, display_metadata: Mapping[str, str] = _EMPTY_DISPLAY_METADATA, now: datetime | None = None, ) -> SessionAuthentication | VerificationUnavailable: """Create or atomically rebind authenticated state and reveal one binding cookie. A caller that already holds a session gets a new identifier rather than keeping the one it arrived with, which is what defeats session fixation. Args: connection: The connection whose session state to write. account: The authenticated account to bind the session to. evidence: Verified method and trait evidence used to create the session. display_metadata: Application-supplied fields to show in the session list. now: Override the clock, for tests and replayable establishment. Returns: The established session and its reveal-once binding token, or ``VerificationUnavailable`` when a dependency failed. """ session = self._writable_http_session(connection.scope) if session is None or not self._valid_login_account(account): return VerificationUnavailable() try: occurred_at = aware_utc_time(self.clock() if now is None else now) expires_at = occurred_at + timedelta(seconds=self.binding.max_age) token, proof = self._issue_binding() command = CreateSessionCommand( session_id=encode_random(self._entropy(SESSION_ID_BYTES)), binding_id=proof.binding_id, binding_digest=proof.digest, account_id=account.account_id, security_epoch=account.security_epoch, created_at=occurred_at, authenticated_at=evidence.authenticated_at if evidence is not None else occurred_at, expires_at=expires_at, display_metadata=display_metadata, ) prior = self._decode_authentication(session.get(_SESSION_AUTHENTICATION_KEY)) event = self._event( occurred_at, operation=SESSION_REBIND if prior is not None else "local.session.create", outcome=OUTCOME_CREATED, account_id=account.account_id, ) record = ( await self.accounts.rebind(prior.session_id, command, event=event) if prior is not None else await self.accounts.create(command, event=event) ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() if record is None or not self._record_matches_command(record, command): return VerificationUnavailable() authentication = SessionAuthentication( session_id=command.session_id, binding_id=command.binding_id, account_id=command.account_id, security_epoch=command.security_epoch, authenticated_at=evidence.authenticated_at if evidence is not None else occurred_at, expires_at=expires_at, assurance_expires_at=evidence.expires_at if evidence is not None else None, methods=evidence.methods if evidence is not None else frozenset({"password"}), traits=(evidence.traits | {"session"}) if evidence is not None else frozenset({"session"}), amr=evidence.amr or tuple(sorted(evidence.methods)) if evidence is not None else ("pwd",), ) preserved = {key: session[key] for key in self.binding.preserve_session_keys if key in session} session.clear() session.update(preserved) session[_SESSION_AUTHENTICATION_KEY] = self._encode_authentication(authentication) self._queue_binding_cookie(connection.scope, token) return authentication
[docs] async def logout( self, connection: ASGIConnection[Any, Any, Any, Any], *, now: datetime | None = None ) -> bool | VerificationUnavailable: """Clear local browser state and atomically revoke the current account-owned record. Args: connection: The connection whose session state to clear. now: Override the clock, for tests and replayable logout. Returns: Whether an active session was revoked, or ``VerificationUnavailable`` when a dependency failed. """ session = self._writable_http_session(connection.scope) if session is None: return VerificationUnavailable() authentication = self._decode_authentication(session.get(_SESSION_AUTHENTICATION_KEY)) self._clear_local_state(connection.scope) if authentication is None: return False try: occurred_at = aware_utc_time(self.clock() if now is None else now) return bool( await self.accounts.revoke_session_for_account( authentication.account_id, authentication.session_id, event=self._event( occurred_at, operation=SESSION_LOGOUT, outcome=OUTCOME_REVOKED, account_id=authentication.account_id, ), ) ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable()
[docs] async def revoke_session( self, connection: ASGIConnection[Any, Any, Any, Any], account_id: str, session_id: str, *, now: datetime | None = None, ) -> bool | VerificationUnavailable: """Atomically revoke one caller-owned session and clear it when current. Args: connection: The connection whose session state to clear if it is the target. account_id: The authenticated caller's account. session_id: The session to revoke. now: Override the clock, for tests and replayable revocation. Returns: Whether an active session was revoked, or ``VerificationUnavailable`` when a dependency failed. A session owned by another account is reported as not revoked rather than as a distinct failure. """ session = self._writable_http_session(connection.scope) if session is None: return VerificationUnavailable() if not strict_text(account_id) or not valid_identifier(session_id): return False try: occurred_at = aware_utc_time(self.clock() if now is None else now) revoked = await self.accounts.revoke_session_for_account( account_id, session_id, event=self._event( occurred_at, operation=SESSION_REVOKE, outcome=OUTCOME_REVOKED, account_id=account_id ), ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() authentication = self._decode_authentication(session.get(_SESSION_AUTHENTICATION_KEY)) if authentication is not None and (authentication.account_id, authentication.session_id) == ( account_id, session_id, ): self._clear_local_state(connection.scope) return bool(revoked)
[docs] async def list_sessions( self, account_id: str, *, current_session_id: str | None = None ) -> tuple[SessionSummary, ...]: """Return only safe account-session inventory projections. Args: account_id: The account whose sessions to list. current_session_id: The caller's own session, flagged as current in the result. Returns: Summaries carrying no binding material, filtered to the named account. """ if not strict_text(account_id): return () records = await self.accounts.list_for_account(account_id) return tuple( SessionSummary( session_id=record.session_id, current=record.session_id == current_session_id, created_at=record.created_at, last_seen_at=record.last_seen_at, expires_at=record.expires_at, display_metadata=record.display_metadata, ) for record in records if record.account_id == account_id )
[docs] def current_authentication(self, connection: ASGIConnection[Any, Any, Any, Any]) -> SessionAuthentication | None: """Return the strictly decoded current local-session projection. Args: connection: The connection to read session state from. Returns: The current session projection, or ``None`` when the connection carries no session or a malformed one. """ session = self._session_mapping(connection.scope) return self._decode_authentication(session.get(_SESSION_AUTHENTICATION_KEY)) if session is not None else None
[docs] def prepare_password_rebind( self, connection: ASGIConnection[Any, Any, Any, Any], account: "LocalAccountState[UserT]", *, now: datetime | None = None, ) -> SessionRebindPlan | VerificationUnavailable: """Prepare reveal-once browser material without mutating registry or session state. Preparation is deliberately separate from activation: the replacement session must not exist until the password mutation it accompanies has committed. Args: connection: The connection whose session is being replaced. account: The account the replacement session will bind to. now: Override the clock, for tests and replayable preparation. Returns: The plan to hand to :meth:`activate_password_rebind`, or ``VerificationUnavailable`` when the caller has no usable session. """ session = self._writable_http_session(connection.scope) current = self.current_authentication(connection) if ( session is None or current is None or not self._valid_login_account(account) or current.account_id != account.account_id ): return VerificationUnavailable() try: occurred_at = aware_utc_time(self.clock() if now is None else now) binding_token, proof = self._issue_binding() command = CreateSessionCommand( session_id=encode_random(self._entropy(SESSION_ID_BYTES)), binding_id=proof.binding_id, binding_digest=proof.digest, account_id=account.account_id, security_epoch=account.security_epoch, created_at=occurred_at, authenticated_at=occurred_at, expires_at=occurred_at + timedelta(seconds=self.binding.max_age), ) return SessionRebindPlan( prior_session_id=current.session_id, command=command, binding_token=binding_token, authenticated_at=occurred_at, ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable()
[docs] async def activate_password_rebind( self, connection: ASGIConnection[Any, Any, Any, Any], plan: SessionRebindPlan, security_epoch: int ) -> bool: """Activate only a replacement record already accepted by the atomic password mutation. Args: connection: The connection whose session state to rewrite. plan: The plan returned by :meth:`prepare_password_rebind`. security_epoch: The epoch the password mutation committed at. Returns: ``True`` when the replacement session became the connection's session. """ session = self._writable_http_session(connection.scope) if session is None or plan.__class__ is not SessionRebindPlan or not valid_security_epoch(security_epoch): self._clear_local_state(connection.scope) return False command = replace(plan.command, security_epoch=security_epoch) try: record = await self.accounts.get(command.session_id) except Exception: # noqa: BLE001 - application-supplied code may raise anything; fail closed record = None if record is None or not self._record_matches_command(record, command): self._clear_local_state(connection.scope) return False authentication = SessionAuthentication( session_id=command.session_id, binding_id=command.binding_id, account_id=command.account_id, security_epoch=command.security_epoch, authenticated_at=command.authenticated_at, expires_at=command.expires_at, ) session[_SESSION_AUTHENTICATION_KEY] = self._encode_authentication(authentication) self._queue_binding_cookie(connection.scope, plan.binding_token) return True
def _issue_binding(self) -> tuple[str, SessionBindingProof]: lookup = self._entropy(LOOKUP_BYTES) secret = self._entropy(SECRET_BYTES) if lookup.__class__ is not bytes or len(lookup) != LOOKUP_BYTES: raise ValueError if secret.__class__ is not bytes or len(secret) != SECRET_BYTES: raise ValueError binding_id = f"{_SESSION_BINDING_PREFIX}{encode_random(lookup)}" token = f"{binding_id}.{encode_random(secret)}" return token, SessionBindingProof(binding_id, self._binding_digest(binding_id, secret)) def _binding_proof(self, token: object) -> SessionBindingProof | None: if ( not isinstance(token, str) or token.__class__ is not str or len(token) != len(_SESSION_BINDING_PREFIX) + _LOOKUP_CHARACTERS + 1 + SECRET_CHARACTERS ): return None binding_id, separator, encoded_secret = token.partition(".") secret = decode_random(encoded_secret, SECRET_BYTES) if separator != "." or not valid_identifier(binding_id, prefix=_SESSION_BINDING_PREFIX) or secret is None: return None return SessionBindingProof(binding_id, self._binding_digest(binding_id, secret)) def _binding_digest(self, binding_id: str, secret: bytes) -> bytes: return hmac_digest(self.binding.pepper, _SESSION_BINDING_DOMAIN + binding_id.encode("ascii") + secret, sha256) @staticmethod def _session_mapping(scope: "Scope") -> MutableMapping[str, object] | None: value = cast("Mapping[str, object]", scope).get("session") return cast("MutableMapping[str, object]", value) if isinstance(value, MutableMapping) else None @classmethod def _writable_http_session(cls, scope: "Scope") -> MutableMapping[str, object] | None: return cls._session_mapping(scope) if scope["type"] == ScopeType.HTTP else None @staticmethod def _encode_authentication(authentication: SessionAuthentication) -> dict[str, object]: return { "version": _SESSION_PAYLOAD_VERSION, "session_id": authentication.session_id, "binding_id": authentication.binding_id, "account_id": authentication.account_id, "security_epoch": authentication.security_epoch, "authenticated_at": authentication.authenticated_at.isoformat(), "expires_at": authentication.expires_at.isoformat(), "assurance_expires_at": ( authentication.assurance_expires_at.isoformat() if authentication.assurance_expires_at is not None else None ), "methods": sorted(authentication.methods), "traits": sorted(authentication.traits), "amr": list(authentication.amr), } @staticmethod def _decode_authentication(value: object) -> SessionAuthentication | None: if not isinstance(value, Mapping): return None payload = cast("Mapping[str, object]", value) legacy_keys = { "version", "session_id", "binding_id", "account_id", "security_epoch", "authenticated_at", "expires_at", } version = payload.get("version") version_two_keys = legacy_keys | {"methods", "traits", "amr"} current_keys = version_two_keys | {"assurance_expires_at"} if ( (version == 1 and set(payload) != legacy_keys) or (version == _SESSION_PAYLOAD_ASSURANCE_VERSION and set(payload) != version_two_keys) or (version == _SESSION_PAYLOAD_VERSION and set(payload) != current_keys) ): return None supported_versions = {1, _SESSION_PAYLOAD_ASSURANCE_VERSION, _SESSION_PAYLOAD_VERSION} if version.__class__ is not int or version not in supported_versions: return None try: return SessionAuthentication( session_id=cast("str", payload["session_id"]), binding_id=cast("str", payload["binding_id"]), account_id=cast("str", payload["account_id"]), security_epoch=cast("int", payload["security_epoch"]), authenticated_at=datetime.fromisoformat(cast("str", payload["authenticated_at"])), expires_at=datetime.fromisoformat(cast("str", payload["expires_at"])), assurance_expires_at=( datetime.fromisoformat(cast("str", payload["assurance_expires_at"])) if version == _SESSION_PAYLOAD_VERSION and payload["assurance_expires_at"] is not None else None ), methods=( frozenset(cast("list[str]", payload["methods"])) if version in {_SESSION_PAYLOAD_ASSURANCE_VERSION, _SESSION_PAYLOAD_VERSION} else frozenset() ), traits=( frozenset(cast("list[str]", payload["traits"])) if version in {_SESSION_PAYLOAD_ASSURANCE_VERSION, _SESSION_PAYLOAD_VERSION} else frozenset({"session"}) ), amr=( tuple(cast("list[str]", payload["amr"])) if version in {_SESSION_PAYLOAD_ASSURANCE_VERSION, _SESSION_PAYLOAD_VERSION} else () ), ) except (TypeError, ValueError): return None @staticmethod def _valid_login_account(account: object) -> bool: return ( strict_text(getattr(account, "account_id", None)) and getattr(account, "active", None) is True and getattr(account, "verified", None) is True and valid_security_epoch(getattr(account, "security_epoch", None)) ) @classmethod def _valid_current_state( # noqa: PLR0913 - explicit configuration surface; every input is named cls, authentication: SessionAuthentication, binding: SessionBindingProof, record: UserAuthSession, *, account: "LocalAccountState[UserT]", current_epoch: object, now: datetime, ) -> bool: if record.__class__ is not UserAuthSession or not cls._valid_login_account(account): return False return ( compare_digest(binding.binding_id.encode("ascii"), record.binding_id.encode("ascii")) and compare_digest(binding.digest, record.binding_digest) and record.session_id == authentication.session_id and record.account_id == authentication.account_id == getattr(account, "account_id", None) and record.security_epoch == authentication.security_epoch == current_epoch == getattr(account, "security_epoch", None) and record.binding_id == authentication.binding_id and record.authenticated_at == authentication.authenticated_at and record.expires_at == authentication.expires_at and now < authentication.expires_at ) @staticmethod def _record_matches_command(record: UserAuthSession, command: CreateSessionCommand) -> bool: return ( record.__class__ is UserAuthSession and record.session_id == command.session_id and record.binding_id == command.binding_id and compare_digest(record.binding_digest, command.binding_digest) and record.account_id == command.account_id and record.security_epoch == command.security_epoch and record.created_at == command.created_at and record.authenticated_at == command.authenticated_at and record.expires_at == command.expires_at ) def _clear_local_state(self, scope: "Scope") -> None: session = self._session_mapping(scope) if session is not None and scope["type"] == ScopeType.HTTP: session.pop(_SESSION_AUTHENTICATION_KEY, None) if scope["type"] == ScopeType.HTTP: cookie = Cookie( key=self.binding.cookie_name, value="", max_age=0, expires=0, domain=self.binding.domain, path=self.binding.path, secure=self.binding.secure, httponly=True, samesite=self.binding.same_site, ) queue_security_response_header(scope, cookie.to_encoded_header()) def _queue_binding_cookie(self, scope: "Scope", token: str) -> None: cookie = Cookie( key=self.binding.cookie_name, value=token, max_age=self.binding.max_age, domain=self.binding.domain, path=self.binding.path, secure=self.binding.secure, httponly=True, samesite=self.binding.same_site, ) queue_security_response_header(scope, cookie.to_encoded_header()) def _entropy(self, length: int) -> bytes: return self.entropy(length) def _event(self, occurred_at: datetime, *, operation: str, outcome: str, account_id: str) -> "SecurityEvent": event_id = self.event_ids() if not strict_text(event_id): raise ValueError return SecurityEvent( event_id=event_id.strip(), occurred_at=occurred_at, operation=operation, outcome=outcome, account_id=account_id, mechanism=self.name, )
def _freeze_display_metadata(value: Mapping[str, str]) -> "Mapping[str, str]": if len(value) > _MAXIMUM_DISPLAY_METADATA: msg = "Session display metadata must contain bounded non-blank text" raise ValueError(msg) total_bytes = 0 for key, item in value.items(): if not strict_text(key) or not strict_text(item): msg = "Session display metadata must contain bounded non-blank text" raise ValueError(msg) item_bytes = len(key.encode("utf-8")) + len(item.encode("utf-8")) total_bytes += item_bytes if item_bytes > _MAXIMUM_DISPLAY_METADATA_ITEM_BYTES or total_bytes > _MAXIMUM_DISPLAY_METADATA_BYTES: msg = "Session display metadata must contain bounded non-blank text" raise ValueError(msg) return MappingProxyType(dict(value)) def _validate_native_session_store(value: object) -> None: if not isinstance(value, NativeSessionStore): msg = "Native session accounts must implement account, epoch, and session registry capabilities" raise ImproperlyConfiguredException(detail=msg) def _validate_binding_cookie_config(config: SessionBindingConfig) -> None: secure_value: object = config.secure allow_insecure_value: object = config.allow_insecure if ( not strict_text(config.cookie_name) or config.cookie_name != config.cookie_name.strip() or any(character in config.cookie_name for character in '()<>@,;:\\"/[]?={} \t') ): msg = "Session binding cookie name must be strict cookie-safe text" raise ImproperlyConfiguredException(detail=msg) if secure_value.__class__ is not bool: msg = "Session binding Secure setting must be boolean" raise ImproperlyConfiguredException(detail=msg) if config.same_site not in {"lax", "strict", "none"}: msg = "Session binding SameSite must be lax, strict, or none" raise ImproperlyConfiguredException(detail=msg) if allow_insecure_value.__class__ is not bool: msg = "Session binding insecure-development opt-in must be boolean" raise ImproperlyConfiguredException(detail=msg) if config.secure and config.allow_insecure: msg = "Session binding insecure-development opt-in requires an insecure cookie" raise ImproperlyConfiguredException(detail=msg) if not config.secure and not config.allow_insecure: msg = "Insecure session binding cookies require explicit development opt-in" raise ImproperlyConfiguredException(detail=msg) if config.same_site == "none" and not config.secure: msg = "Session binding SameSite=None requires Secure" raise ImproperlyConfiguredException(detail=msg) _validate_binding_cookie_scope(config) def _validate_binding_cookie_scope(config: SessionBindingConfig) -> None: if config.cookie_name.startswith("__Host-") and (config.secure, config.path, config.domain) != (True, "/", None): msg = "__Host- session binding cookies require Secure, Path=/, and no Domain" raise ImproperlyConfiguredException(detail=msg) if ( not strict_text(config.path) or not config.path.startswith("/") or any(character.isspace() or ord(character) < _ASCII_CONTROL_LIMIT for character in config.path) ): msg = "Session binding cookie path must be an absolute printable path" raise ImproperlyConfiguredException(detail=msg) if config.domain is not None and ( not strict_text(config.domain) or config.domain != config.domain.strip() or any(character.isspace() or ord(character) < _ASCII_CONTROL_LIMIT for character in config.domain) ): msg = "Session binding cookie domain must be strict printable text" raise ImproperlyConfiguredException(detail=msg) def _validate_binding_lifetime_config(config: SessionBindingConfig) -> None: if config.max_age.__class__ is not int or config.max_age < 1: msg = "Session binding maximum age must be a positive integer" raise ImproperlyConfiguredException(detail=msg) if ( config.touch_interval.__class__ is not timedelta or config.touch_interval <= timedelta(0) or config.touch_interval > timedelta(seconds=config.max_age) ): msg = "Session touch interval must be positive and no longer than the binding lifetime" raise ImproperlyConfiguredException(detail=msg) def _validate_preserved_session_keys(keys: tuple[str, ...]) -> None: if keys.__class__ is not tuple: msg = "Preserved session keys must be an immutable tuple" raise ImproperlyConfiguredException(detail=msg) if ( len(frozenset(keys)) != len(keys) or _SESSION_AUTHENTICATION_KEY in keys or any(not strict_text(key) for key in keys) ): msg = "Preserved session keys must be unique non-security text" raise ImproperlyConfiguredException(detail=msg) @dataclass(frozen=True, slots=True) class _SessionCredential: authentication: SessionAuthentication binding: SessionBindingProof