Coverage for src/lexigram/auth/authn/api_key.py: 100%
50 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"""API Key AuthenticatorProtocol for Lexigram Auth.
3Authenticates incoming requests by extracting an API key from the
4``X-API-Key`` request header or the ``api_key`` query parameter, then
5delegating validation to a configurable lookup function or static dict.
6"""
8from __future__ import annotations
10from collections.abc import Awaitable, Callable
11from dataclasses import dataclass, field
12from typing import TYPE_CHECKING, Any
14from lexigram.auth.exceptions import AuthenticationError
15from lexigram.auth.models.user import User
16from lexigram.logging import get_logger
18if TYPE_CHECKING:
19 from lexigram.result import Result
21logger = get_logger(__name__)
23# A lookup can be a plain dict mapping key → User, or an async/sync callable
24# that accepts the raw key string and returns a User or None.
25LookupCallable = Callable[[str], Awaitable[User | None] | (User | None)]
28@dataclass
29class APIKeyConfig:
30 """Configuration for :class:`APIKeyAuthenticator`.
32 Attributes:
33 header_name: HTTP header to inspect for the API key.
34 Defaults to ``"X-API-Key"``.
35 query_param: URL query parameter name to fall back to when the header
36 is absent. Defaults to ``"api_key"``.
37 lookup: Either a ``dict[str, User]`` mapping raw key strings to their
38 owners, or an async/sync callable that accepts the raw key and
39 returns ``User | None``. This field is **required**.
40 """
42 lookup: LookupCallable | dict[str, Any]
43 header_name: str = "X-API-Key"
44 query_param: str = "api_key"
45 # Injected via field so subclasses can override without touching __init__
46 _lookup_is_dict: bool = field(init=False, repr=False)
48 def __post_init__(self) -> None:
49 self._lookup_is_dict = isinstance(self.lookup, dict)
52class APIKeyAuthenticator:
53 """Authenticates requests by validating an API key.
55 Key extraction order:
56 1. ``request_context["headers"][config.header_name]``
57 2. ``request_context["query_params"][config.query_param]``
59 If the key is absent **or** the lookup returns ``None`` an
60 ``Err(AuthenticationError)`` is returned. On success ``Ok(User)``
61 is returned.
63 Example::
65 config = APIKeyConfig(
66 lookup={"sk_live_abc123": admin_user},
67 )
68 authenticator = APIKeyAuthenticator(config)
70 result = await authenticator.authenticate({
71 "headers": {"X-API-Key": "sk_live_abc123"},
72 "query_params": {},
73 })
75 if result.is_ok():
76 user = result.unwrap()
77 """
79 def __init__(self, config: APIKeyConfig) -> None:
80 """Initialise the authenticator.
82 Args:
83 config: Key-extraction and lookup configuration.
84 """
85 self._config = config
87 def _extract_key(self, request_context: dict[str, Any]) -> str | None:
88 """Extract the raw API key from the request context.
90 Checks the configured header first, then falls back to the query
91 parameter. Header matching is case-insensitive.
93 Args:
94 request_context: Mapping with optional ``"headers"`` and
95 ``"query_params"`` sub-dicts.
97 Returns:
98 Raw key string, or ``None`` if not present.
99 """
100 headers: dict[str, str] = request_context.get("headers") or {}
101 query_params: dict[str, str] = request_context.get("query_params") or {}
103 # Case-insensitive header lookup
104 header_name_lower = self._config.header_name.lower()
105 for name, value in headers.items():
106 if name.lower() == header_name_lower:
107 return value or None
109 return query_params.get(self._config.query_param) or None
111 async def _resolve_user(self, raw_key: str) -> User | None:
112 """Invoke the configured lookup to resolve the user.
114 Args:
115 raw_key: Plain-text API key submitted by the caller.
117 Returns:
118 :class:`~lexigram.auth.models.user.User` if found, ``None``
119 otherwise.
120 """
121 import inspect
123 lookup = self._config.lookup
125 if self._config._lookup_is_dict:
126 return lookup.get(raw_key) # type: ignore[union-attr]
128 result = lookup(raw_key) # type: ignore[operator]
129 if inspect.isawaitable(result):
130 return await result
131 return result
133 async def authenticate(
134 self,
135 request_context: dict[str, Any],
136 ) -> Result[User, AuthenticationError]:
137 """Authenticate a request using its API key.
139 Args:
140 request_context: A mapping that **must** contain at least one of:
142 * ``"headers"``: ``dict[str, str]`` of HTTP request headers.
143 * ``"query_params"``: ``dict[str, str]`` of URL query params.
145 Returns:
146 ``Ok(User)`` when a valid API key is found.
147 ``Err(AuthenticationError)`` when the key is absent, invalid, or
148 the lookup returns ``None``.
149 """
150 from lexigram.result import Err, Ok
152 raw_key = self._extract_key(request_context)
153 if not raw_key:
154 logger.debug("api_key_missing", context_keys=list(request_context.keys()))
155 return Err(AuthenticationError("API key is missing from the request"))
157 user = await self._resolve_user(raw_key)
158 if user is None:
159 logger.warning("api_key_invalid", key_prefix=raw_key[:8])
160 return Err(AuthenticationError("Invalid or unrecognised API key"))
162 logger.info(
163 "api_key_authenticated",
164 user_id=getattr(user, "user_id", None),
165 )
166 return Ok(user)
169__all__ = ["APIKeyAuthenticator", "APIKeyConfig"]