Source code for litestar_security.accounts._stores

"""Persistence and capability protocols implemented by applications."""

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol, TypeVar, runtime_checkable

from litestar.exceptions import ImproperlyConfiguredException

from litestar_security.accounts._internal import strict_text, valid_security_epoch
from litestar_security.accounts._purpose_tokens import (
    NotificationCommand,
    PurposeTokenDelivery,
    RegistrationCommand,
    TokenIssue,
)
from litestar_security.accounts._records import (
    LocalAccountState,
    LoginMethod,
    PasswordChangeOutcome,
    PasswordCredentialState,
    PasswordResetOutcome,
    RegistrationMode,
    RegistrationOutcome,
    RevokeLoginMethodOutcome,
    SecurityEvent,
    VerificationOutcome,
)
from litestar_security.authentication import InvalidCredentials, VerificationUnavailable

if TYPE_CHECKING:
    from datetime import datetime

__all__ = (
    "AccountLookup",
    "LocalAccountCapabilities",
    "LoginMethodStore",
    "PasswordCredentialStore",
    "RecoveryTokenStore",
    "RegistrationPolicy",
    "RegistrationStore",
    "SecurityEpochStore",
    "SecurityEpochValidator",
    "VerificationTokenStore",
)

UserT = TypeVar("UserT")


[docs] @dataclass(frozen=True, slots=True) class RegistrationPolicy: """Explicit self-service registration policy.""" mode: RegistrationMode require_verification: bool = True
[docs] @classmethod def disabled(cls) -> "RegistrationPolicy": """Disable self-service registration. Returns: A policy that generates no registration route. """ return cls(mode=RegistrationMode.DISABLED)
[docs] @classmethod def public(cls, *, require_verification: bool = True) -> "RegistrationPolicy": """Enable public self-service registration. Args: require_verification: Issue a verification token with the account and leave the account unverified until that token is consumed. Returns: A policy that generates an open registration route. """ return cls(mode=RegistrationMode.PUBLIC, require_verification=require_verification)
[docs] @classmethod def invite_only(cls, *, require_verification: bool = True) -> "RegistrationPolicy": """Require an atomic invitation consume during registration. Args: require_verification: Issue a verification token with the account and leave the account unverified until that token is consumed. Returns: A policy whose registration route additionally requires an invitation token. """ return cls(mode=RegistrationMode.INVITE_ONLY, require_verification=require_verification)
[docs] @runtime_checkable class AccountLookup(Protocol[UserT]): """Resolve the minimal application account projection."""
[docs] async def find_for_login(self, normalized_identifier: str) -> "LocalAccountState[UserT] | None": """Find an account through an already-normalized identifier. The caller normalizes before calling, so match the stored value exactly rather than normalizing again. Args: normalized_identifier: The identifier as normalized by the configured normalizer. Returns: The account projection, or ``None`` when no account matches. """ ... # pragma: no cover
[docs] async def get_by_id(self, account_id: str) -> "LocalAccountState[UserT] | None": """Resolve an account by its stable security identifier. Args: account_id: The stable account identifier carried on credentials. Returns: The account projection, or ``None`` when the account no longer exists. """ ... # pragma: no cover
[docs] @runtime_checkable class PasswordCredentialStore(Protocol): """Store password credentials through atomic security operations."""
[docs] async def get_password_state(self, account_id: str) -> PasswordCredentialState | None: """Load one atomic password hash, account-state, and security-epoch snapshot. Read the hash, active/verified projection, and epoch in one operation. Values read separately can describe a state that never existed during a concurrent deactivation or verification-state change. Args: account_id: The account whose credential state to read. Returns: The paired hash, account-state projection, and epoch, or ``None`` when the account has no password. """ ... # pragma: no cover
[docs] async def compare_and_replace_password( self, account_id: str, expected_hash: str, password_hash: str, *, event: SecurityEvent ) -> bool: """Atomically replace a hash only when its expected value is current. The comparison is what makes concurrent changes safe, so it must happen inside the same operation as the write. Args: account_id: The account whose password to replace. expected_hash: The hash the caller read and expects to still be stored. password_hash: The replacement hash. event: The audit event to commit with the replacement. Rejecting it must fail the replacement. Returns: ``True`` when the stored hash matched and was replaced, ``False`` when it had already changed. """ ... # pragma: no cover
[docs] async def replace_password_and_bump_epoch( self, account_id: str, password_hash: str, *, expected_epoch: int, event: SecurityEvent ) -> PasswordChangeOutcome: """Atomically replace a password and increment the security epoch. Advancing the epoch is what invalidates credentials issued before the change, so it must commit with the new hash or not at all. Args: account_id: The account whose password to replace. password_hash: The replacement hash. expected_epoch: The epoch the caller read; a different stored epoch is a conflict. event: The audit event to commit with the replacement. Rejecting it must fail the replacement. Returns: The outcome, carrying the new epoch only when the replacement committed. """ ... # pragma: no cover
[docs] @runtime_checkable class LoginMethodStore(Protocol): """Maintain viable login methods through guarded atomic operations."""
[docs] async def register_login_method(self, account_id: str, method: LoginMethod, *, event: SecurityEvent) -> None: """Register one login method and its durable event. Args: account_id: The account gaining the method. method: The method to record. event: The audit event to commit with the registration. Rejecting it must fail the registration. """ ... # pragma: no cover
[docs] async def revoke_login_method( self, account_id: str, method_id: str, *, require_remaining: bool = True, event: SecurityEvent ) -> RevokeLoginMethodOutcome: """Revoke a method without removing the final viable method by default. Args: account_id: The account owning the method. method_id: The method to revoke. require_remaining: Refuse the revocation when it would leave the account with no way to sign in. event: The audit event to commit with the revocation. Rejecting it must fail the revocation. Returns: The outcome, distinguishing an absent method from a refused final one. """ ... # pragma: no cover
[docs] @runtime_checkable class RegistrationStore(Protocol[UserT]): """Create an account and consume any invitation atomically."""
[docs] async def register( # noqa: PLR0913 - explicit configuration surface; every input is named self, command: RegistrationCommand, password_hash: str, *, invitation_digest: bytes | None, verification: PurposeTokenDelivery | None, now: "datetime", event: SecurityEvent, ) -> RegistrationOutcome[UserT]: """Commit registration, invitation, verification, notification, and event. Every part commits together. Creating the account but failing to consume the invitation would let one invitation create unlimited accounts. Args: command: The normalized identifier and display name to register. password_hash: The encoded hash for the new account. invitation_digest: The digest of the presented invitation to consume, or ``None`` under a policy that requires no invitation. verification: The verification token and notification to store with the account, or ``None`` when the policy requires no verification. now: The commit timestamp. event: The audit event to commit with the registration. Rejecting it must fail the registration. Returns: The outcome, carrying the account projection only when it was created. """ ... # pragma: no cover
[docs] @runtime_checkable class VerificationTokenStore(Protocol): """Issue and atomically consume account-verification tokens."""
[docs] async def issue(self, issue: TokenIssue, notification: NotificationCommand, *, event: SecurityEvent) -> None: """Commit a verification issue, notification, and durable event. Store the digest the issue carries, never the token itself: the token is the secret sent to the account holder. Args: issue: The token digest, account binding, and expiry to store. notification: The delivery the application should send. event: The audit event to commit with the issue. Rejecting it must fail the issue. """ ... # pragma: no cover
[docs] async def issue_absent(self) -> None: """Perform one durable round trip that commits nothing. Called instead of :meth:`issue` when the identifier resolves to no eligible account. The durable step MUST cost the same whether or not the identifier resolves: an implementation that answers quickly for unknown accounts makes a present account measurably slower to probe, defeating the shared-response guarantee. Commit, notify, and mutate nothing. """ ... # pragma: no cover
[docs] async def consume_and_verify( self, token_id: str, digest: bytes, *, now: "datetime", event: SecurityEvent ) -> VerificationOutcome: """Consume a verification token and verify its account atomically. Marking the token used and marking the account verified must commit together, so one token can never verify twice. Args: token_id: The identifier carried by the presented token. digest: The digest to compare against the stored one. now: The timestamp to evaluate expiry against. event: The audit event to commit with the consumption. Rejecting it must fail the consumption. Returns: The outcome, carrying the account and its epoch only when consumed. """ ... # pragma: no cover
[docs] @runtime_checkable class RecoveryTokenStore(Protocol): """Issue and atomically consume password-recovery tokens."""
[docs] async def issue(self, issue: TokenIssue, notification: NotificationCommand, *, event: SecurityEvent) -> None: """Commit a recovery issue, notification, and durable event. Store the digest the issue carries, never the token itself: the token is the secret sent to the account holder. Args: issue: The token digest, account binding, and expiry to store. notification: The delivery the application should send. event: The audit event to commit with the issue. Rejecting it must fail the issue. """ ... # pragma: no cover
[docs] async def issue_absent(self) -> None: """Perform one durable round trip that commits nothing. Called instead of :meth:`issue` when the identifier resolves to no eligible account. The durable step MUST cost the same whether or not the identifier resolves: an implementation that answers quickly for unknown accounts makes a present account measurably slower to probe, defeating the shared-response guarantee. Commit, notify, and mutate nothing. """ ... # pragma: no cover
[docs] async def consume_and_reset( self, token_id: str, digest: bytes, new_password_hash: str, *, now: "datetime", event: SecurityEvent ) -> PasswordResetOutcome: """Consume only at its issued epoch, then reset password and advance epoch atomically. The epoch check is what stops a stale recovery token from undoing a password change made after the token was issued. Args: token_id: The identifier carried by the presented token. digest: The digest to compare against the stored one. new_password_hash: The encoded replacement hash. now: The timestamp to evaluate expiry against. event: The audit event to commit with the reset. Rejecting it must fail the reset. Returns: The outcome, carrying the account and its new epoch only when reset. """ ... # pragma: no cover
[docs] @runtime_checkable class SecurityEpochStore(Protocol): """Resolve the exact current account security epoch."""
[docs] async def current_epoch(self, account_id: str) -> int | None: """Return the current epoch or ``None`` for an absent account. Read authoritative state rather than a cache: a stale epoch keeps revoked credentials working. 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] @runtime_checkable class LocalAccountCapabilities( AccountLookup[UserT], PasswordCredentialStore, LoginMethodStore, VerificationTokenStore, RecoveryTokenStore, SecurityEpochStore, Protocol[UserT], ): """Structural account capabilities required by every local-auth profile."""
[docs] @dataclass(frozen=True, slots=True) class SecurityEpochValidator: """Validate one presented epoch against authoritative application state.""" store: SecurityEpochStore = field(repr=False) def __post_init__(self) -> None: """Require the exact epoch lookup capability.""" if not isinstance(object.__getattribute__(self, "store"), SecurityEpochStore): msg = "Security epoch validator store must implement SecurityEpochStore" raise ImproperlyConfiguredException(detail=msg)
[docs] async def validate( self, account_id: str, presented_epoch: int ) -> InvalidCredentials | VerificationUnavailable | None: """Return ``None`` only when the exact current epoch matches. Args: account_id: The account named by the presented credential. presented_epoch: The epoch the credential was issued at. Returns: ``None`` when the credential is still current, ``InvalidCredentials`` when the epoch has moved on, and ``VerificationUnavailable`` when the store could not be read. """ if not strict_text(account_id) or not valid_security_epoch(presented_epoch): return InvalidCredentials() try: current_epoch = await self.store.current_epoch(account_id) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() if not valid_security_epoch(current_epoch) or current_epoch != presented_epoch: return InvalidCredentials() return None