Coverage for src/lexigram/auth/di/sub_providers/authentication_provider.py: 90%

111 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 00:58 +0800

1"""Authentication provider - handles user authentication only.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import secrets 

7from typing import TYPE_CHECKING, Annotated, Any, cast 

8 

9from pydantic import SecretStr 

10 

11from lexigram.auth.authn.password_hasher import ( 

12 Argon2idKeyDerivation, 

13 Argon2idPasswordHasher, 

14 ComposedPasswordHasher, 

15) 

16from lexigram.auth.authn.security import PasswordHasher, PasswordPolicy 

17from lexigram.auth.authn.services import AuthenticationService 

18from lexigram.auth.storage.token_store import InMemoryUserStore, UserStoreProtocol 

19from lexigram.contracts import HealthCheckResult, HealthStatus, ProviderPriority 

20from lexigram.contracts.auth import PasswordHasherProtocol, PasswordPolicyProtocol 

21from lexigram.contracts.core import HookRegistryProtocol 

22from lexigram.contracts.core.config import Environment 

23from lexigram.contracts.exceptions import ConfigurationError 

24from lexigram.di.markers import Inject 

25from lexigram.di.provider import Provider 

26from lexigram.logging import get_logger 

27 

28__all__ = ["AuthenticationProvider"] 

29 

30 

31from lexigram.result import Err, Result 

32 

33if TYPE_CHECKING: 

34 from lexigram.auth.config import AuthConfig 

35 from lexigram.auth.mfa.manager import MFAManager 

36 from lexigram.contracts.auth.exceptions import TokenError 

37 from lexigram.contracts.auth.token import VerifiedToken 

38 from lexigram.contracts.core.di import ( 

39 BootContainerProtocol, 

40 ContainerRegistrarProtocol, 

41 ) 

42 

43logger = get_logger(__name__) 

44 

45 

46def _build_token_manager( 

47 secret_key: str | None, 

48 jwt_algorithm: str, 

49 cache_service: Any, 

50 required_audience: str | None = None, 

51) -> Any: 

52 """Build a JWTTokenManager from the supplied credentials. 

53 

54 Generates an ephemeral RSA keypair when ``jwt_algorithm`` is RS-family 

55 and no PEM key is provided via ``secret_key``. 

56 

57 Args: 

58 secret_key: HMAC secret or PEM-encoded RSA private key. 

59 jwt_algorithm: JWT signing algorithm (e.g. "HS256", "RS256"). 

60 cache_service: Optional cache backend for token blacklisting. 

61 required_audience: Forwarded to :class:`JWTTokenManager`; when set, 

62 every verified token must carry a matching ``aud`` claim. 

63 

64 Returns: 

65 A configured :class:`~lexigram.auth.authn.jwt.JWTTokenManager`. 

66 """ 

67 from lexigram.auth.authn.jwt import JWTTokenManager 

68 

69 if jwt_algorithm.startswith(("RS", "PS")): 

70 if secret_key and "-----BEGIN" in secret_key: 

71 keys: dict[str, Any] = {"default": {"private": secret_key}} 

72 else: 

73 try: 

74 from cryptography.hazmat.primitives import serialization 

75 from cryptography.hazmat.primitives.asymmetric import rsa 

76 except (ImportError, ModuleNotFoundError) as exc: 

77 raise RuntimeError( 

78 "cryptography is required for RS/PS algorithms" 

79 ) from exc 

80 

81 private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) 

82 private_pem = private_key.private_bytes( 

83 encoding=serialization.Encoding.PEM, 

84 format=serialization.PrivateFormat.PKCS8, 

85 encryption_algorithm=serialization.NoEncryption(), 

86 ).decode("utf-8") 

87 public_pem = ( 

88 private_key.public_key() 

89 .public_bytes( 

90 encoding=serialization.Encoding.PEM, 

91 format=serialization.PublicFormat.SubjectPublicKeyInfo, 

92 ) 

93 .decode("utf-8") 

94 ) 

95 keys = { 

96 "default": { 

97 "private": SecretStr(private_pem), 

98 "public": SecretStr(public_pem), 

99 } 

100 } 

101 logger.warning( 

102 "AuthenticationProvider: generated ephemeral RSA keypair; " 

103 "provide persistent keys in production", 

104 ) 

105 return JWTTokenManager( 

106 current_key_id="default", 

107 keys=keys, 

108 algorithm=jwt_algorithm, 

109 cache_service=cache_service, 

110 required_audience=required_audience, 

111 ) 

112 

113 # Symmetric algorithm: use secret_key directly (single-secret mode) 

114 env = Environment.from_env() 

115 _STRICT_ENVS = {Environment.PRODUCTION, Environment.STAGING} 

116 if not secret_key: 

117 if env in _STRICT_ENVS: 

118 raise ConfigurationError( 

119 f"CRITICAL SECURITY: JWT secret_key is required in {env.value.upper()} " 

120 "but none was provided. " 

121 "Set LEX_AUTH__TOKEN__SECRET_KEY (or token.secret_key in config)." 

122 ) 

123 effective_secret = secrets.token_urlsafe(32) 

124 logger.warning( 

125 "jwt_ephemeral_secret_generated", 

126 environment=env.value, 

127 reason="No JWT secret_key provided; using a generated ephemeral secret. " 

128 "Tokens will be invalidated on restart. Provide a stable secret for production.", 

129 ) 

130 else: 

131 effective_secret = secret_key 

132 return JWTTokenManager( 

133 current_key_id="default", 

134 keys={"default": SecretStr(effective_secret)}, 

135 algorithm=jwt_algorithm, 

136 cache_service=cache_service, 

137 required_audience=required_audience, 

138 ) 

139 

140 

141class AuthenticationProvider(Provider): 

142 """User authentication ONLY (login/logout/validation). 

143 

144 ``config`` is the primary parameter and drives all defaults. The optional 

145 keyword arguments are *override points* for callers that need to supply 

146 custom implementations (e.g., a production-grade :class:`UserStoreProtocol` backed 

147 by a database, or a pre-built token manager). 

148 

149 Args: 

150 config: The resolved :class:`~lexigram.auth.config.AuthConfig`. 

151 JWT credentials (``config.token.secret_key``, ``config.token.algorithm``) 

152 are used to build a :class:`~lexigram.auth.authn.jwt.JWTTokenManager` 

153 automatically when no ``token_manager`` override is provided. 

154 password_policy: Override the password policy derived from ``config.password``. 

155 When *None* the policy is built from config; falls back to ``PasswordPolicy()`` 

156 when config is also absent. 

157 user_store: Override the user store. Defaults to :class:`InMemoryUserStore`. 

158 token_manager: Supply a fully constructed token manager. When *None* and 

159 ``config.token`` is present the manager is built automatically from the 

160 JWT configuration. 

161 cache_service: Optional cache backend passed to the auto-built token manager 

162 for token blacklisting. Ignored when ``token_manager`` is provided directly. 

163 mfa_service: Optional MFA manager for multi-factor authentication flows. 

164 """ 

165 

166 def __init__( 

167 self, 

168 config: Annotated[AuthConfig, Inject] | None = None, 

169 *, 

170 password_policy: PasswordPolicy | None = None, 

171 user_store: UserStoreProtocol | None = None, 

172 token_manager: Any = None, 

173 cache_service: Any = None, 

174 mfa_service: MFAManager | None = None, 

175 ) -> None: 

176 super().__init__(name="authentication", priority=ProviderPriority.SECURITY) 

177 self.config = config 

178 if password_policy is not None: 

179 self.password_policy = password_policy 

180 elif config is not None and config.password is not None: 

181 self.password_policy = PasswordPolicy.from_config(config.password) 

182 else: 

183 self.password_policy = PasswordPolicy() 

184 self.user_store = user_store or InMemoryUserStore() 

185 self.mfa_service = mfa_service 

186 

187 # Auto-create a JWTTokenManager from config when no override is supplied. 

188 if ( 

189 token_manager is None 

190 and config is not None 

191 and getattr(config, "token", None) is not None 

192 ): 

193 token_manager = _build_token_manager( 

194 secret_key=config.token.secret_key.get_secret_value(), 

195 jwt_algorithm=config.token.algorithm, 

196 cache_service=cache_service, 

197 required_audience=config.token.required_audience, 

198 ) 

199 

200 self.token_manager = token_manager 

201 self.delegation_manager: Any = None 

202 self._service: AuthenticationService | None = None 

203 self._hasher: PasswordHasherProtocol | None = None 

204 

205 @property 

206 def password_hasher(self) -> PasswordHasherProtocol: 

207 """Composed Argon2id-default hasher with a bcrypt legacy shim. 

208 

209 Builds the single composed instance (ODD-1 Option A): new hashes use 

210 Argon2id with OWASP-2024 parameters; stored bcrypt hashes keep 

211 verifying through the legacy shim and upgrade on the next login. 

212 """ 

213 if self._hasher is None: 

214 primary = Argon2idPasswordHasher( 

215 kdf=Argon2idKeyDerivation( 

216 config=self.config.password if self.config else None, 

217 ), 

218 ) 

219 self._hasher = ComposedPasswordHasher( 

220 primary=primary, 

221 legacy=PasswordHasher(), 

222 ) 

223 return self._hasher 

224 

225 @property 

226 def service(self) -> AuthenticationService: 

227 """Get or create the authentication service.""" 

228 if self._service is None: 

229 self._service = AuthenticationService( 

230 password_policy=self.password_policy, 

231 user_store=self.user_store, 

232 token_manager=self.token_manager, 

233 password_hasher=self.password_hasher, 

234 ) 

235 return self._service 

236 

237 async def get_user(self, user_id: str) -> Any | None: 

238 """Fetch a user by their ID. 

239 

240 Satisfies :class:`~lexigram.contracts.auth.AuthProviderProtocol`. 

241 

242 Args: 

243 user_id: The unique identifier of the user to retrieve. 

244 

245 Returns: 

246 The user object, or ``None`` if not found. 

247 """ 

248 return await self.user_store.get_user_by_id(user_id) 

249 

250 async def register(self, container: ContainerRegistrarProtocol) -> None: 

251 """Register authentication services with the container. 

252 

253 Registers the protocols for dependency injection across extensions 

254 without direct imports. The single ``PasswordHasherProtocol`` 

255 binding is the composed hasher (Argon2id default, bcrypt legacy 

256 shim) — one registration per protocol per AGENTS.md §2.6. 

257 """ 

258 # Register concrete implementations 

259 container.singleton(PasswordPolicy, lambda: self.password_policy) 

260 container.singleton(UserStoreProtocol, lambda: self.user_store) 

261 container.singleton(AuthenticationService, lambda: self.service) 

262 

263 # Register protocol mappings for cross-extension compatibility (CROSS-EXT-02) 

264 # This allows other extensions to depend on protocols rather than concrete classes 

265 container.singleton(PasswordHasherProtocol, self.password_hasher) 

266 container.singleton(PasswordPolicyProtocol, lambda: self.password_policy) 

267 from lexigram.contracts.auth import AuthProviderProtocol 

268 

269 # Only register self as AuthProviderProtocol when no other provider 

270 # has already claimed this slot (e.g., an application-level AuthService). 

271 if not container.has(AuthProviderProtocol): 

272 container.singleton(AuthProviderProtocol, lambda: self) 

273 

274 async def boot(self, container: BootContainerProtocol) -> None: 

275 """Initialize authentication provider and register with kernel health registry.""" 

276 logger.info("AuthenticationProvider started") 

277 hooks = await container.resolve_optional(HookRegistryProtocol) 

278 self.service.set_hook_registry(hooks) 

279 if self.token_manager is not None and hasattr( 

280 self.token_manager, "set_hook_registry" 

281 ): 

282 self.token_manager.set_hook_registry(hooks) 

283 

284 # Token revocation needs a cache backend; when none was supplied at 

285 # construction, adopt the application default cache so 

286 # ``logout_all_user_tokens`` works without extra wiring. 

287 if self.token_manager is not None and hasattr( 

288 self.token_manager, "set_blacklist_resolver" 

289 ): 

290 # Ordering-proof: auth boots before cache providers, so hand a 

291 # deferred source over the root resolver instead of resolving now. 

292 self.token_manager.set_blacklist_resolver( 

293 lambda: _resolve_default_cache_sync(container) 

294 ) 

295 

296 if container is not None and hasattr(container, "resolve"): 

297 with contextlib.suppress( 

298 ImportError, AttributeError, RuntimeError, TypeError 

299 ): 

300 from lexigram.contracts.observability.metrics import ( 

301 HealthCheckRegistryProtocol, 

302 ) 

303 

304 registry = await container.resolve_optional(HealthCheckRegistryProtocol) 

305 if registry is not None: 

306 registry.add("authentication", self.health_check) 

307 

308 async def shutdown(self) -> None: 

309 """Shutdown authentication provider.""" 

310 logger.info("AuthenticationProvider shutdown") 

311 

312 async def verify_token(self, token: str) -> Result[VerifiedToken, TokenError]: 

313 """Verify a JWT token and return a ``Result`` with the decoded payload. 

314 

315 Satisfies :class:`~lexigram.contracts.auth.AuthProviderProtocol`. 

316 

317 Delegates to ``token_manager.verify_token()`` when a token manager 

318 is configured. Returns an ``Err`` result if no token manager is set 

319 or if the token is invalid for any expected domain reason. 

320 

321 Infrastructure failures (cache unavailable, network errors) are still 

322 raised as exceptions and must be handled by the caller. 

323 

324 Args: 

325 token: The JWT token string to verify. 

326 

327 Returns: 

328 ``Ok(VerifiedToken)`` if the token is valid and not revoked, 

329 or ``Err(TokenError)`` for expected domain failures. 

330 """ 

331 from lexigram.auth.exceptions import TokenInvalidError 

332 

333 if self.token_manager is None: 

334 return Err(TokenInvalidError("Authentication not configured")) # type: ignore[arg-type] 

335 return cast( 

336 "Result[VerifiedToken, TokenError]", 

337 await self.token_manager.verify_token(token), 

338 ) 

339 

340 async def validate_session(self, token: str) -> Any: 

341 """Validate a session token and return user information. 

342 

343 Delegates to the underlying authentication service's 

344 ``get_user_from_token`` method. Returns a 

345 ``Result[VerifiedToken, TokenError]`` or ``None`` when no token 

346 manager is configured. 

347 

348 Args: 

349 token: The session or JWT token to validate. 

350 

351 Returns: 

352 ``Result[VerifiedToken, TokenError]`` on success/failure, or ``None``. 

353 """ 

354 if self.token_manager is None: 

355 return None 

356 return await self.service.get_user_from_token(token) 

357 

358 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult: 

359 """Check authentication provider health.""" 

360 return HealthCheckResult( 

361 component=self.name, 

362 status=HealthStatus.HEALTHY, 

363 details={ 

364 "service": "authentication", 

365 "password_policy": str(self.password_policy), 

366 "user_store_type": type(self.user_store).__name__, 

367 }, 

368 )