Source code for litestar_security.config

"""Configuration for the Litestar Security plugin."""

from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field, replace
from datetime import timedelta
from inspect import iscoroutinefunction
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast

from litestar.exceptions import ImproperlyConfiguredException

from litestar_security._docs import RouteDocs
from litestar_security.authentication import (
    AuthenticationMechanism,
    AuthenticationPolicy,
    AuthorizationResolver,
    CredentialSlot,
)
from litestar_security.headers import SecurityHeadersConfig
from litestar_security.schema import WirePolicy
from litestar_security.websocket import WebSocketSecurityConfig
from litestar_security.workers import BlockingIntegration, NoOpSecurityMetrics, SecurityMetrics, WorkerLimits

if TYPE_CHECKING:
    from litestar.dto.types import RenameStrategy

    from litestar_security.accounts import (
        AttestationTrustMapper,
        LocalAuthConfig,
        LoginMethodStore,
        RecoveryCodePepper,
        SecurityEventSink,
        TOTPPolicy,
    )
    from litestar_security.providers.api_key import APIKeyConfig
    from litestar_security.providers.iap import GoogleIAPConfig
    from litestar_security.providers.jwks import JWKSProvider
    from litestar_security.providers.jwt import LocalJWKSConfig
    from litestar_security.providers.oauth import OAuthConfig, ProtectedResourceConfig
    from litestar_security.providers.oidc import ServiceTokenConfig

__all__ = (
    "BlockingIntegration",
    "ExternalCSRF",
    "MFAConfig",
    "NoOpSecurityMetrics",
    "PasskeyConfig",
    "RaisedErrorSchema",
    "SecurityConfig",
    "SecurityMetrics",
    "WorkerLimits",
)

UserT = TypeVar("UserT")
SyncT = TypeVar("SyncT")
ResultT = TypeVar("ResultT")
_EMPTY_METRIC_ATTRIBUTES: Mapping[str, str] = MappingProxyType({})
_MAXIMUM_WORKER_TOKENS = 1_024
_ASCII_CONTROL_LIMIT = 32


[docs] @dataclass(frozen=True, slots=True) class RaisedErrorSchema: """Declare how application exception handling renders raised route errors. Args: schema: The body type application exception handlers serialize. media_type: The response content type used for that serialized body. Raises: ImproperlyConfiguredException: If ``schema`` is not a type or ``media_type`` is not a non-blank string. """ schema: type[object] media_type: str def __post_init__(self) -> None: """Validate and normalize the application-owned response declaration.""" if not isinstance(cast("object", self.schema), type): message = "Raised-error schema must be a type" raise ImproperlyConfiguredException(detail=message) media_type = cast("object", self.media_type) if media_type.__class__ is not str or not cast("str", media_type).strip(): # type: ignore[redundant-cast] # mypy narrows this; pyright does not message = "Raised-error media type must be a non-blank string" raise ImproperlyConfiguredException(detail=message) object.__setattr__(self, "media_type", cast("str", media_type).strip()) # type: ignore[redundant-cast] # mypy narrows this; pyright does not
[docs] @dataclass(frozen=True, slots=True) class ExternalCSRF: """Declare a named application-owned CSRF coverage validator.""" name: str validate: Callable[[str, str, AuthenticationPolicy], bool] = field(repr=False) def __post_init__(self) -> None: """Normalize the integration name.""" name_value = cast("object", self.name) if name_value.__class__ is not str: message = "External CSRF integration name must be text" raise ImproperlyConfiguredException(detail=message) name = cast("str", name_value).strip() # type: ignore[redundant-cast] # mypy narrows this; pyright does not if not name: message = "External CSRF integration name must not be blank" raise ImproperlyConfiguredException(detail=message) validator = cast("object", self.validate) if not callable(validator): message = "External CSRF validation hook must be callable" raise ImproperlyConfiguredException(detail=message) if iscoroutinefunction(validator): message = "External CSRF validation hook must be synchronous" raise ImproperlyConfiguredException(detail=message) object.__setattr__(self, "name", name)
[docs] @dataclass(frozen=True, slots=True) class MFAConfig: """Configure MFA capabilities without selecting a persistence technology.""" store: object secret_protector: object = field(repr=False) policy: "TOTPPolicy | None" = None recovery_peppers: "Sequence[RecoveryCodePepper]" = field(default=(), repr=False) login_methods: "LoginMethodStore | None" = field(default=None, repr=False) events: "SecurityEventSink | None" = field(default=None, repr=False) step_up_store: object | None = field(default=None, repr=False) require_at_login: bool = False login_challenge_store: object | None = field(default=None, repr=False) route_prefix: str = "/auth" issuer: str = "Litestar Security" register_routes: bool = True docs: RouteDocs = field(default_factory=RouteDocs, repr=False) mfa_service: object = field(init=False, repr=False, compare=False) step_up_service: object | None = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: """Build project-owned services from explicit application ports.""" from litestar_security.accounts import ( # noqa: PLC0415 - account services load only when configured MFALoginChallengeStore, MFAService, StepUpService, StepUpStore, ) mfa_service_kwargs: dict[str, object] = { "store": self.store, "secret_protector": self.secret_protector, "issuer": self.issuer, "recovery_peppers": tuple(self.recovery_peppers), "login_methods": self.login_methods, } if self.policy is not None: mfa_service_kwargs["policy"] = self.policy if self.events is not None: mfa_service_kwargs["events"] = self.events object.__setattr__(self, "mfa_service", MFAService(**cast("Any", mfa_service_kwargs))) step_up_store = self.step_up_store if self.step_up_store is not None else self.store object.__setattr__( self, "step_up_service", StepUpService(cast("Any", step_up_store)) if isinstance(step_up_store, StepUpStore) else None, ) object.__setattr__(self, "route_prefix", _feature_route_prefix(self.route_prefix)) register_routes_value = cast("object", self.register_routes) if register_routes_value.__class__ is not bool: msg = "MFA route registration must be boolean" raise ImproperlyConfiguredException(detail=msg) if self.docs.__class__ is not RouteDocs: msg = "MFA documentation metadata must be RouteDocs" raise ImproperlyConfiguredException(detail=msg) require_at_login_value = cast("object", self.require_at_login) if require_at_login_value.__class__ is not bool: msg = "MFA require_at_login must be boolean" raise ImproperlyConfiguredException(detail=msg) login_challenge_store = self.login_challenge_store if self.login_challenge_store is not None else self.store if self.require_at_login and not isinstance(login_challenge_store, MFALoginChallengeStore): msg = "MFA login challenge store must implement MFALoginChallengeStore" raise ImproperlyConfiguredException(detail=msg) object.__setattr__(self, "login_challenge_store", login_challenge_store) if (self.register_routes or self.require_at_login) and ( not self.recovery_peppers or self.login_methods is None ): msg = "Generated MFA routes require recovery-code peppers and a login-method store" raise ImproperlyConfiguredException(detail=msg)
[docs] @dataclass(frozen=True, slots=True) class PasskeyConfig: """Configure exact WebAuthn relying-party and persistence boundaries.""" store: object challenge_store: object rp_id: str origins: Sequence[str] rp_name: str = "Litestar Security" algorithms: Sequence[int] = (-8, -7, -257) challenge_ttl: timedelta = timedelta(minutes=5) allow_insecure_localhost: bool = False worker_timeout: float = 10.0 attestation_trust: "AttestationTrustMapper | None" = field(default=None, repr=False) login_methods: "LoginMethodStore | None" = field(default=None, repr=False) events: "SecurityEventSink | None" = field(default=None, repr=False) step_up_store: object | None = field(default=None, repr=False) route_prefix: str = "/auth" register_routes: bool = True docs: RouteDocs = field(default_factory=RouteDocs, repr=False) passkey_service: object = field(init=False, repr=False, compare=False) step_up_service: object | None = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: """Freeze relying-party origins and build the project-owned service.""" from litestar_security.accounts import ( # noqa: PLC0415 - account services load only when configured PasskeyService, StepUpService, StepUpStore, ) object.__setattr__(self, "origins", tuple(self.origins)) object.__setattr__(self, "algorithms", tuple(self.algorithms)) passkey_service_kwargs = { "store": self.store, "challenge_store": self.challenge_store, "rp_id": self.rp_id, "rp_name": self.rp_name, "origins": self.origins, "algorithms": self.algorithms, "challenge_ttl": self.challenge_ttl, "allow_insecure_localhost": self.allow_insecure_localhost, "worker_timeout": self.worker_timeout, "attestation_trust": self.attestation_trust, "login_methods": self.login_methods, } if self.events is not None: passkey_service_kwargs["events"] = self.events object.__setattr__(self, "passkey_service", PasskeyService(**cast("Any", passkey_service_kwargs))) object.__setattr__( self, "step_up_service", StepUpService(cast("Any", self.step_up_store)) if isinstance(self.step_up_store, StepUpStore) else None, ) object.__setattr__(self, "route_prefix", _feature_route_prefix(self.route_prefix)) register_routes_value = cast("object", self.register_routes) if register_routes_value.__class__ is not bool: msg = "Passkey route registration must be boolean" raise ImproperlyConfiguredException(detail=msg) if self.docs.__class__ is not RouteDocs: msg = "Passkey documentation metadata must be RouteDocs" raise ImproperlyConfiguredException(detail=msg) if self.register_routes and self.login_methods is None: msg = "Generated passkey routes require a login-method store" raise ImproperlyConfiguredException(detail=msg)
def _feature_route_prefix(value: object) -> str: if not isinstance(value, str): msg = "MFA and passkey route prefixes must be absolute non-root paths" raise ImproperlyConfiguredException(detail=msg) normalized = value.rstrip("/") if ( not normalized.startswith("/") or normalized == "" or "//" in normalized or any(character.isspace() or ord(character) < _ASCII_CONTROL_LIMIT for character in normalized) ): msg = "MFA and passkey route prefixes must be absolute non-root paths" raise ImproperlyConfiguredException(detail=msg) return normalized def _exclude_patterns(value: object) -> tuple[str, ...] | str | None: if value is None or isinstance(value, str): return value if isinstance(value, Sequence): given = tuple(cast("Sequence[object]", value)) patterns = tuple(pattern for pattern in given if isinstance(pattern, str)) if len(patterns) == len(given): return patterns msg = "Route exclusion patterns must be text or a sequence of text" raise ImproperlyConfiguredException(detail=msg)
[docs] @dataclass(slots=True) class SecurityConfig(Generic[UserT]): """Configure the per-application security runtime.""" slots: Sequence[CredentialSlot[Any]] = () mechanisms: Sequence[AuthenticationMechanism[Any, Any, UserT]] = () max_openapi_combinations: int = 32 external_csrf: ExternalCSRF | None = None exclude: Sequence[str] | str | None = None """Regular expressions matched against a route path to exclude it from security. Mirrors ``JWTAuth.exclude``: a single pattern or a sequence of patterns, joined into one expression and compiled with :mod:`re`. A pattern is anchored at the start of the route path, so ``"^/static"`` and ``"/static"`` both exclude ``/static/{file_path:path}`` while a bare ``"static"`` does not. Exclusion is total and applies when the route is compiled, not per request: an excluded route is never authenticated, carries no principal, and contributes an anonymous security requirement to OpenAPI rather than the configured schemes. A route that declares its own ``auth=`` and also matches a pattern is a contradiction and is rejected at startup. """ require_default: bool = False local_auth: "LocalAuthConfig[UserT] | None" = None local_jwks: "LocalJWKSConfig | None" = None oauth: "OAuthConfig | None" = None protected_resource: "ProtectedResourceConfig | None" = None """Describe this application as an OAuth 2.1 protected resource. When set, the plugin publishes the RFC 9728 metadata document at ``/.well-known/oauth-protected-resource`` so an authorization server or a client can discover which issuers this resource trusts, which scopes it understands, and how a bearer token may be presented to it. The route is unauthenticated, as the specification requires. """ mfa: MFAConfig | None = None passkeys: PasskeyConfig | None = None api_key: "APIKeyConfig | None" = None iap: "GoogleIAPConfig[UserT] | None" = None service_token: "ServiceTokenConfig | None" = None headers: SecurityHeadersConfig | None = None websocket: WebSocketSecurityConfig = field(default_factory=WebSocketSecurityConfig) authorization_resolver: AuthorizationResolver[UserT] | None = field(default=None, repr=False) jwks_providers: Sequence["JWKSProvider"] = () jwks_warmup_failure: Literal["fail_startup", "lazy"] = "fail_startup" wire_rename: "RenameStrategy | None" = None """How generated request and response members are spelled on the wire. ``None`` keeps the field names as Python spells them, which is snake_case. Any of ``"lower"``, ``"upper"``, ``"camel"``, ``"pascal"``, and ``"kebab"`` selects a named strategy, and a ``Callable[[str], str]`` covers a house convention outside those five. The choice reaches the OpenAPI document as well as the wire, so a generated client follows it without further work. A handful of generated schemas opt out because their member names belong to a specification rather than to this library - the RFC 6749 token response, the OIDC back-channel logout form, and the bodies Litestar's own exception handling renders. """ wire_forbid_unknown_fields: bool = True """Whether an unrecognized member in a request body is a decoding error. Strictness applies to decoding, so it constrains request schemas only. Rejecting the unknown member is what keeps a stale or misspelled optional field from resolving to its default and producing a wrong but successful request. """ raised_error_schema: RaisedErrorSchema | None = None """The body type and media type application handlers use for raised errors. Generated routes raise their denial statuses through the application's exception handlers. Set this when those handlers render a body other than Litestar's default :class:`~litestar_security.RouteError`. The declaration changes only generated-route OpenAPI response specifications; it does not install an exception handler or alter runtime responses. """ def __post_init__(self) -> None: """Freeze ordered authentication collections.""" if self.max_openapi_combinations < 1: msg = "max_openapi_combinations must be positive" raise ImproperlyConfiguredException(detail=msg) external_csrf = cast("object | None", self.external_csrf) if external_csrf is not None and not isinstance(external_csrf, ExternalCSRF): msg = "External CSRF configuration must be an ExternalCSRF assertion" raise ImproperlyConfiguredException(detail=msg) headers = cast("object | None", self.headers) if headers is not None and not isinstance(headers, SecurityHeadersConfig): msg = "Browser security headers must be a SecurityHeadersConfig" raise ImproperlyConfiguredException(detail=msg) raised_error_schema = cast("object | None", self.raised_error_schema) if raised_error_schema is not None and not isinstance(raised_error_schema, RaisedErrorSchema): msg = "Raised-error schema must be a RaisedErrorSchema declaration" raise ImproperlyConfiguredException(detail=msg) self._validate_protected_resource() self.exclude = _exclude_patterns(self.exclude) self.slots = tuple(self.slots) self.mechanisms = tuple(self.mechanisms) local_accounts = getattr(self.local_auth, "accounts", None) local_epoch = getattr(local_accounts, "current_epoch", None) if self.websocket.current_security_epoch is None and callable(local_epoch): self.websocket = replace(self.websocket, current_security_epoch=local_epoch) jwks_providers = list(self.jwks_providers) for provider in ( None if self.iap is None else self.iap.jwks, None if self.service_token is None else self.service_token.jwks, ): if provider is not None and all(existing is not provider for existing in jwks_providers): jwks_providers.append(provider) self.jwks_providers = tuple(jwks_providers) if self.jwks_warmup_failure not in {"fail_startup", "lazy"}: msg = "JWKS warmup failure mode must be 'fail_startup' or 'lazy'" raise ImproperlyConfiguredException(detail=msg) self.wire_policy()
[docs] def wire_policy(self) -> WirePolicy: """Return the wire convention every generated route body is built with. Returns: The casing strategy and unknown-field policy as one hashable value. Raises: ImproperlyConfiguredException: If the strategy is neither one of the named strategies nor a callable, or the unknown-field policy is not boolean. """ return WirePolicy(rename=self.wire_rename, forbid_unknown_fields=self.wire_forbid_unknown_fields)
def _validate_protected_resource(self) -> None: if self.protected_resource is None: return from litestar_security.providers.oauth import ( # noqa: PLC0415 - the OAuth tree loads only when configured ProtectedResourceConfig, ) if not isinstance(cast("object", self.protected_resource), ProtectedResourceConfig): msg = "Protected resource metadata must be a ProtectedResourceConfig" raise ImproperlyConfiguredException(detail=msg)