Coverage for src/lexigram/auth/config.py: 98%
133 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
1"""Configuration models for Lexigram Auth.
3This module provides Pydantic models for configuring authentication
4and authorization in Lexigram applications.
6Example:
7 from lexigram.auth.config import AuthConfig
9 # From YAML
10 config = AuthConfig.from_yaml("application.yaml")
12 # From environment
13 config = AuthConfig() # reads LEX_AUTH__* env vars
14"""
16from __future__ import annotations
18from dataclasses import dataclass
19from typing import Any, ClassVar, cast
21from lexigram.auth import constants as const
22from lexigram.config import BaseConfig
23from lexigram.contracts.core.config import Environment
24from lexigram.logging import get_logger
25from lexigram.validation import ConfigDict, Field, SecretStr, model_validator
27_logger = get_logger(__name__)
30@dataclass(init=False)
31class AuthUserConfig(BaseConfig):
32 """Single user configuration for bootstrapping.
34 The configuration historically used ``username`` as the primary
35 identifier. We now prefer ``name`` but keep ``username`` for
36 backwards-compatibility; the validator below will map ``username``
37 to ``name`` when the latter is missing.
38 """
40 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
42 # ``username`` is kept for legacy support but not required
43 username: str | None = Field(None, description="Legacy username")
44 name: str = Field(..., description="User name (preferred over username)")
45 email: str = Field(..., description="Email address")
46 password: str | None = Field(default=None, description="Plain password")
47 password_hash: str | None = Field(default=None, description="Pre-hashed password")
48 roles: list[str] = Field(default_factory=list, description="List of role names")
49 is_active: bool = Field(default=True, description="Whether user is active")
51 @model_validator(mode="before")
52 def _handle_username(self, values: dict[str, Any]) -> dict[str, Any]:
53 # migrate ``username`` -> ``name`` if necessary
54 if "username" in values and "name" not in values:
55 values["name"] = values.pop("username")
56 return values
59@dataclass(init=False)
60class AuthRoleConfig(BaseConfig):
61 """Role configuration with permissions and inheritance."""
63 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
65 name: str = Field(..., description="Role name")
66 description: str = Field(default="", description="Role description")
67 permissions: list[str] = Field(
68 default_factory=list,
69 description="Permission patterns",
70 )
71 inherits: list[str] = Field(
72 default_factory=list,
73 description="Parent roles to inherit from",
74 )
77@dataclass(init=False)
78class RBACConfig(BaseConfig):
79 """RBAC system configuration."""
81 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
83 enabled: bool = Field(default=True, description="Enable RBAC enforcement")
84 superuser_bypass: bool = Field(
85 default=True,
86 description="Allow superuser role to bypass all checks",
87 )
88 default_role: str = Field(
89 default="viewer",
90 description="Default role for new users",
91 )
92 cache_permissions: bool = Field(
93 default=True,
94 description="Cache resolved permissions",
95 )
96 permission_cache_ttl: int = Field(
97 default=300,
98 description="Permission cache TTL in seconds",
99 )
102@dataclass(init=False)
103class JWTConfig(BaseConfig):
104 """JWT Configuration
106 JWT verification policy
107 -----------------------
108 The framework enforces verified-only JWT decoding. Signature verification
109 is never disabled.
111 - ``PRODUCTION`` / ``STAGING``: A secret is **required** and must meet
112 strength checks (no known default value, >= 32 bytes for HS algorithms).
113 An explicit ``required_audience`` is **mandatory** so tokens cannot
114 cross service boundaries.
115 - ``DEVELOPMENT``: A missing secret falls back to a generated ephemeral
116 secret so signature verification **stays enabled**; tokens are
117 invalidated on restart. Set ``LEX_AUTH__TOKEN__SECRET_KEY`` for a
118 stable development secret.
119 """
121 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
123 from lexigram.contracts.core import Duration
125 secret_key: SecretStr = Field(..., description="Secret key for signing tokens")
126 algorithm: str = Field(
127 default=const.DEFAULT_TOKEN_ALGORITHM, description="Algorithm"
128 )
129 access_token_expire: Duration = Field(
130 default=Duration.minutes(const.DEFAULT_ACCESS_TOKEN_EXPIRE_MINUTES),
131 description="Access token expiry duration",
132 )
133 refresh_token_expire: Duration = Field(
134 default=Duration.days(const.DEFAULT_REFRESH_TOKEN_EXPIRE_DAYS),
135 description="Refresh token expiry duration",
136 )
137 id_token_expire: Duration = Field(
138 default=Duration.hours(1),
139 description="ID token expiry duration",
140 )
141 key_rotation_grace_period: Duration = Field(
142 default=Duration.seconds(const.DEFAULT_JWT_KEY_ROTATION_GRACE_PERIOD_SECONDS),
143 description=(
144 "Duration during which tokens signed by a rotated-out key remain "
145 "accepted. Prevents immediate logout on key rotation."
146 ),
147 )
148 required_audience: str | None = Field(
149 default=None,
150 description=(
151 "Expected ``aud`` claim for every token verified by this service. "
152 "When set, tokens whose audience does not match are rejected outright. "
153 "Leave as ``None`` only for internal, single-service deployments where "
154 "audience segregation is not required."
155 ),
156 )
158 @model_validator(mode="after")
159 def validate_jwt_security(self) -> JWTConfig:
160 """Enforce verified-only JWT policy based on deployment environment."""
161 if not isinstance(self.secret_key, SecretStr):
162 self.secret_key = SecretStr(self.secret_key)
163 env = self.environment
164 _STRICT_ENVS = {Environment.PRODUCTION, Environment.STAGING}
166 if env in _STRICT_ENVS:
167 # Validate secret quality in strict environments.
168 if self.secret_key.get_secret_value() in ("change-me", "your-secret-key"):
169 raise ValueError(
170 "CRITICAL SECURITY ERROR: Default JWT secret_key detected in "
171 f"{env.value.upper()}.\n"
172 "You MUST set a secure secret key via LEX_AUTH__TOKEN__SECRET_KEY.",
173 )
174 if (
175 self.algorithm.startswith("HS")
176 and len(self.secret_key.get_secret_value()) < 32
177 ):
178 raise ValueError(
179 f"SECURITY ERROR: {self.algorithm} requires a secret of at "
180 f"least 32 bytes in {env.value}.\n"
181 "Either provide a strong secret (e.g. secrets.token_hex(32)) "
182 "or switch to RS256 for asymmetric key security.",
183 )
184 if self.required_audience is None:
185 raise ValueError(
186 f"SECURITY ERROR: JWT required_audience is required in {env.value.upper()}.\n"
187 "Without an audience, tokens verified by this service are accepted "
188 "by any service sharing the secret. Set "
189 "LEX_AUTH__TOKEN__REQUIRED_AUDIENCE or token.required_audience in config.",
190 )
192 elif env == Environment.DEVELOPMENT:
193 _logger.info(
194 "jwt_verification_policy",
195 environment=env.value,
196 mode="verified_only",
197 )
198 return self
201@dataclass(init=False)
202class PasswordConfig(BaseConfig):
203 """Password complexity and validation configuration.
205 Controls minimum and maximum length, required character classes, and
206 patterns that are explicitly banned (e.g., common passwords).
207 """
209 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
211 min_length: int = Field(default=12, description="Minimum password length")
212 max_length: int = Field(default=128, description="Maximum password length")
213 require_uppercase: bool = Field(
214 default=True,
215 description="Require at least one uppercase letter",
216 )
217 require_lowercase: bool = Field(
218 default=False,
219 description="Require at least one lowercase letter",
220 )
221 require_digits: bool = Field(
222 default=True,
223 description="Require at least one digit",
224 )
225 require_special: bool = Field(
226 default=False,
227 description="Require at least one special character (non-alphanumeric)",
228 )
229 banned_patterns: list[str] = Field(
230 default_factory=list,
231 description=(
232 "Substrings that must not appear in the password (case-insensitive). "
233 "Use to reject common passwords or the user's own name."
234 ),
235 )
236 bcrypt_rounds: int = Field(
237 default=12,
238 description="bcrypt cost factor for new hashes (minimum 12 in production)",
239 )
240 argon2_memory_cost: int = Field(
241 default=65536,
242 description="Argon2id memory cost in KiB (OWASP floor is 19456)",
243 )
244 argon2_time_cost: int = Field(
245 default=3,
246 description="Argon2id time cost",
247 )
248 argon2_parallelism: int = Field(
249 default=4,
250 description="Argon2id parallelism",
251 )
253 @model_validator(mode="after")
254 def validate_cost_factors(self) -> PasswordConfig:
255 """Reject below-floor cost factors in production and staging.
257 Fail-closed below the OWASP floors rather than silently hashing
258 weakly: bcrypt rounds below 12 and Argon2id memory below 19456 KiB
259 are refused. Development remains unconstrained so tests and local
260 setup can use cheaper parameters.
261 """
262 env = self.environment
263 _STRICT_ENVS = {Environment.PRODUCTION, Environment.STAGING}
264 if env in _STRICT_ENVS:
265 if self.bcrypt_rounds < 12:
266 raise ValueError(
267 "SECURITY ERROR: bcrypt_rounds must be at least 12 "
268 f"in {env.value.upper()} (got {self.bcrypt_rounds}).",
269 )
270 if self.argon2_memory_cost < 19456:
271 raise ValueError(
272 "SECURITY ERROR: argon2_memory_cost must be at least 19456 KiB "
273 f"in {env.value.upper()} (got {self.argon2_memory_cost}).",
274 )
275 return self
278@dataclass(init=False)
279class AuthMiddlewareConfig(BaseConfig):
280 """Configuration for authentication middleware."""
282 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
284 exclude_paths: list[str] = Field(
285 default_factory=list,
286 description="Paths excluded from auth",
287 )
288 backend: str = Field(default="session", description="Auth backend type")
289 header_name: str = Field(
290 default="Authorization",
291 description="Header name for token",
292 )
293 scheme: str = Field(default=const.DEFAULT_TOKEN_TYPE, description="Token scheme")
294 roles_required: list[str] = Field(
295 default_factory=list,
296 description="Roles required",
297 )
298 permissions_required: list[str] = Field(
299 default_factory=list,
300 description="Permissions required",
301 )
302 optional_auth: bool = Field(
303 default=False,
304 description="Whether authentication is optional",
305 )
306 login_url: str | None = Field(default=None, description="URL to redirect for login")
307 exclude_prefixes: list[str] = Field(
308 default_factory=list,
309 description="Path prefixes excluded",
310 )
311 login_rate_limit: str = Field(
312 default="5/minute",
313 description="Rate limit for auth endpoints",
314 )
317@dataclass(init=False)
318class AuthConfig(BaseConfig):
319 """Hierarchical root configuration for Lexigram Auth.
321 Attributes:
322 name: Configuration name (default: "auth")
323 enabled: Whether the auth module is enabled
324 users: Initial users to create
325 roles: Role definitions for RBAC
326 rbac: RBAC system configuration
327 token: JWT configuration
328 middleware: Authentication middleware configuration
329 secret_key: Secret key for signing tokens
330 admin_email: Initial admin email
331 admin_password: Initial admin password
332 login_rate_limit: Rate limit for login endpoints
333 oauth2_providers: OAuth2 provider configurations
334 """
336 model_config = cast(
337 "ConfigDict",
338 {
339 "env_prefix": "LEX_AUTH__",
340 "env_nested_delimiter": "__",
341 "extra": "ignore",
342 },
343 )
345 config_section: ClassVar[str] = "auth"
347 name: str = "auth"
348 enabled: bool = True
349 users: list[AuthUserConfig] = Field(
350 default_factory=list,
351 description="Initial users",
352 )
353 roles: dict[str, AuthRoleConfig] = Field(
354 default_factory=dict,
355 description="Role definitions",
356 )
357 rbac: RBACConfig = Field(default_factory=RBACConfig, description="RBAC config")
358 password: PasswordConfig = Field(
359 default_factory=PasswordConfig,
360 description="Password complexity rules",
361 )
362 token: JWTConfig = Field(description="JWT Configuration")
363 middleware: AuthMiddlewareConfig = Field(
364 default_factory=AuthMiddlewareConfig,
365 description="Middleware Configuration",
366 )
367 secret_key: str = Field(description="Secret key for signing")
368 admin_email: str | None = Field(default=None, description="Initial admin email")
369 admin_password: str | None = Field(
370 default=None,
371 description="Initial admin password",
372 )
373 login_rate_limit: str = Field(default="5/minute", description="Default rate limit")
374 oauth2_providers: dict[str, dict[str, str]] = Field(
375 default_factory=dict,
376 description="OAuth2 configs",
377 )
378 max_sessions_per_user: int | None = Field(
379 default=None,
380 ge=1,
381 description="Maximum number of concurrent sessions allowed per user. "
382 "``None`` (the default) means unlimited. When a positive integer is "
383 "set and the limit is exceeded, the least-recently-used session is evicted.",
384 )
385 relay_verification: bool = Field(
386 default=False,
387 description=(
388 "Enable binding ``RelayAuthVerifierProtocol`` for the relay "
389 "gateway's inbound API-key authentication. When ``False`` "
390 "(default) no relay binding is registered."
391 ),
392 )
394 @model_validator(mode="after")
395 def validate_security(self) -> AuthConfig:
396 """Ensure secure settings in production."""
397 env = self.environment
398 insecure_defaults = ("change-me", "your-secret-key", "secret", "password")
400 if env.value == "production":
401 if self.secret_key.lower() in insecure_defaults:
402 raise ValueError(
403 "CRITICAL SECURITY ERROR: Default auth secret_key in PRODUCTION.",
404 )
405 if self.admin_password and self.admin_password.lower() in insecure_defaults:
406 raise ValueError(
407 "CRITICAL SECURITY ERROR: Default admin_password in PRODUCTION.",
408 )
409 return self
412__all__ = [
413 "AuthConfig",
414 "AuthMiddlewareConfig",
415 "AuthRoleConfig",
416 "AuthUserConfig",
417 "JWTConfig",
418 "PasswordConfig",
419 "RBACConfig",
420]