Coverage for src/lexigram/web/integrations/auth.py: 23%
61 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Authentication integration for WebProvider."""
3from __future__ import annotations
5import inspect
6from typing import Any, cast
8from starlette.applications import Starlette
10from lexigram.di.container import Container
11from lexigram.logging import get_logger
13logger = get_logger(__name__)
16class AuthIntegration:
17 """Handles authentication middleware configuration."""
19 @staticmethod
20 def _is_invocable_authenticator(candidate: Any) -> bool:
21 """Check the candidate can be called as ``authenticate(request)``.
23 Args:
24 candidate: Resolved service candidate.
26 Returns:
27 True if the candidate is safe to invoke with a single request argument.
28 """
29 authenticate = getattr(candidate, "authenticate", None)
30 if not callable(authenticate):
31 return False
32 try:
33 signature = inspect.signature(authenticate)
34 except (TypeError, ValueError):
35 return True
36 positional: list[inspect.Parameter] = [
37 p
38 for p in signature.parameters.values()
39 if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
40 ]
41 if positional and positional[0].name in ("self", "cls"):
42 positional = positional[1:]
43 required = [p for p in positional if p.default is p.empty]
44 return len(required) <= 1
46 @staticmethod
47 async def configure(app: Starlette, container: Container, web_config: Any) -> None:
48 """Configure authentication middleware."""
49 from lexigram.contracts.auth import IdentityResolverProtocol
50 from lexigram.contracts.auth.guard import AuthenticatorProtocol
51 from lexigram.contracts.exceptions import ContainerError
52 from lexigram.web.middleware.auth import AuthenticationMiddleware
54 identity_resolver = await container.resolve_optional(
55 cast("Any", IdentityResolverProtocol)
56 )
58 authenticators = []
59 try:
60 candidates = await container.resolve_all(cast("Any", AuthenticatorProtocol))
61 for candidate in candidates:
62 if AuthIntegration._is_invocable_authenticator(candidate):
63 authenticators.append(candidate)
64 else:
65 logger.warning(
66 "skipped_incompatible_authenticator",
67 service=type(candidate).__name__,
68 )
69 logger.info("resolved_authenticators", count=len(authenticators))
70 except (LookupError, RuntimeError, ContainerError) as e:
71 logger.warning("failed_to_resolve_authenticators", error=str(e))
73 app.add_middleware(
74 AuthenticationMiddleware,
75 authenticators=authenticators,
76 exclude_paths=web_config.auth_exclude_paths,
77 enable_identity_resolution=web_config.enable_identity_resolution,
78 identity_resolver=identity_resolver,
79 )
80 logger.info("Authentication middleware configured")
82 await AuthIntegration._add_role_guard(app, container, web_config)
84 @staticmethod
85 async def _add_role_guard(
86 app: Starlette, container: Container, web_config: Any
87 ) -> None:
88 """Register the role guard middleware after authentication.
90 Roles are resolved per request from the container-bound
91 :class:`~lexigram.web.middleware.role_guard.RoleResolverProtocol`,
92 never from JWT claims. When rules are declared but no resolver is
93 bound, startup fails fast — enforcement must never be silently off.
95 Args:
96 app: The Starlette application to configure.
97 container: The DI container.
98 web_config: The WebConfig driving the rules.
99 """
100 from starlette.middleware import Middleware
102 from lexigram.contracts.exceptions import ContainerError
103 from lexigram.contracts.exceptions.config import ConfigurationError
104 from lexigram.web.middleware.role_guard import (
105 RoleGuardMiddleware,
106 RoleGuardRule,
107 RoleResolverProtocol,
108 )
110 role_guard = getattr(web_config, "role_guard", None)
111 rules = role_guard.rules if role_guard is not None else []
112 if not rules:
113 return
115 resolver = None
116 try:
117 resolver = await container.resolve(cast("Any", RoleResolverProtocol))
118 except (LookupError, RuntimeError, ContainerError) as e:
119 logger.warning("failed_to_resolve_role_resolver", error=str(e))
121 if resolver is None:
122 raise ConfigurationError(
123 "web.role_guard.rules declared but no RoleResolverProtocol is "
124 "bound in the container. Bind one to enable role enforcement."
125 )
127 app.user_middleware.append(
128 Middleware(
129 RoleGuardMiddleware,
130 rules=[
131 RoleGuardRule(path=rule.path, roles=list(rule.roles))
132 for rule in rules
133 ],
134 resolver=resolver,
135 )
136 )
137 logger.info("Role guard middleware configured", rules=len(rules))