csrd-auth 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- csrd/auth/__init__.py +58 -0
- csrd/auth/_authenticators.py +220 -0
- csrd/auth/_factory.py +93 -0
- csrd/auth/_key_providers.py +219 -0
- csrd/auth/_protocols.py +29 -0
- csrd/auth/py.typed +0 -0
- csrd_auth-0.1.0.dist-info/METADATA +14 -0
- csrd_auth-0.1.0.dist-info/RECORD +9 -0
- csrd_auth-0.1.0.dist-info/WHEEL +4 -0
csrd/auth/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Pluggable authentication for FastAPI applications.
|
|
2
|
+
|
|
3
|
+
The auth system is built around two pluggable protocols:
|
|
4
|
+
|
|
5
|
+
1. :class:`Authenticator` — how to validate a token and produce claims.
|
|
6
|
+
2. :class:`KeyProvider` — how to resolve the cryptographic key(s) used
|
|
7
|
+
for JWT verification.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._authenticators import (
|
|
11
|
+
CallbackAuthenticator,
|
|
12
|
+
ChainedAuthenticator,
|
|
13
|
+
JWTAuthenticator,
|
|
14
|
+
RemoteAuthenticator,
|
|
15
|
+
StaticAuthenticator,
|
|
16
|
+
_default_claims_mapper,
|
|
17
|
+
)
|
|
18
|
+
from ._factory import (
|
|
19
|
+
create_bearer_dependency,
|
|
20
|
+
create_jwt_bearer,
|
|
21
|
+
)
|
|
22
|
+
from ._key_providers import (
|
|
23
|
+
CallbackKeyProvider,
|
|
24
|
+
EnvKeyProvider,
|
|
25
|
+
JWKSKeyProvider,
|
|
26
|
+
MultiKeyProvider,
|
|
27
|
+
StaticKeyProvider,
|
|
28
|
+
_coerce_key_provider,
|
|
29
|
+
)
|
|
30
|
+
from ._protocols import (
|
|
31
|
+
AuthCallback,
|
|
32
|
+
Authenticator,
|
|
33
|
+
KeyCallback,
|
|
34
|
+
KeyProvider,
|
|
35
|
+
KeySource,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = (
|
|
39
|
+
"AuthCallback",
|
|
40
|
+
"Authenticator",
|
|
41
|
+
"CallbackAuthenticator",
|
|
42
|
+
"CallbackKeyProvider",
|
|
43
|
+
"ChainedAuthenticator",
|
|
44
|
+
"EnvKeyProvider",
|
|
45
|
+
"JWKSKeyProvider",
|
|
46
|
+
"JWTAuthenticator",
|
|
47
|
+
"KeyCallback",
|
|
48
|
+
"KeyProvider",
|
|
49
|
+
"KeySource",
|
|
50
|
+
"MultiKeyProvider",
|
|
51
|
+
"RemoteAuthenticator",
|
|
52
|
+
"StaticAuthenticator",
|
|
53
|
+
"StaticKeyProvider",
|
|
54
|
+
"_coerce_key_provider",
|
|
55
|
+
"_default_claims_mapper",
|
|
56
|
+
"create_bearer_dependency",
|
|
57
|
+
"create_jwt_bearer",
|
|
58
|
+
)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# ruff: noqa: B904
|
|
2
|
+
"""Built-in authenticator implementations."""
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import jwt as pyjwt
|
|
11
|
+
from fastapi import HTTPException, Request
|
|
12
|
+
from starlette.status import HTTP_401_UNAUTHORIZED
|
|
13
|
+
|
|
14
|
+
from csrd.models.claims import UserClaims
|
|
15
|
+
|
|
16
|
+
from ._key_providers import _coerce_key_provider
|
|
17
|
+
from ._protocols import AuthCallback, Authenticator, KeySource
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _default_claims_mapper(payload: dict[str, Any]) -> UserClaims:
|
|
23
|
+
sub = payload.get("sub", "")
|
|
24
|
+
user_name = (
|
|
25
|
+
payload.get("user_name") or payload.get("preferred_username") or payload.get("email") or ""
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
authorities = payload.get("authorities") or payload.get("roles") or []
|
|
29
|
+
if isinstance(authorities, str):
|
|
30
|
+
authorities = authorities.split()
|
|
31
|
+
|
|
32
|
+
return UserClaims(
|
|
33
|
+
sub=sub,
|
|
34
|
+
user_name=user_name,
|
|
35
|
+
authorities=list(authorities),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class JWTAuthenticator:
|
|
41
|
+
"""Decode and verify a JWT token locally using PyJWT."""
|
|
42
|
+
|
|
43
|
+
key: KeySource | None = None
|
|
44
|
+
algorithms: list[str] = field(default_factory=lambda: ["HS256"])
|
|
45
|
+
audience: str | None = None
|
|
46
|
+
issuer: str | None = None
|
|
47
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
claims_mapper: Callable[[dict[str, Any]], UserClaims] | None = None
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
self._key_provider = _coerce_key_provider(self.key)
|
|
52
|
+
|
|
53
|
+
async def __call__(self, request: Request, token: str) -> UserClaims:
|
|
54
|
+
mapper = self.claims_mapper or _default_claims_mapper
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
unverified_headers = pyjwt.get_unverified_header(token)
|
|
58
|
+
except pyjwt.DecodeError:
|
|
59
|
+
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Malformed token")
|
|
60
|
+
|
|
61
|
+
resolved = self._key_provider(unverified_headers, request.app)
|
|
62
|
+
if inspect.isawaitable(resolved):
|
|
63
|
+
resolved = await resolved
|
|
64
|
+
|
|
65
|
+
decode_kwargs: dict[str, Any] = {
|
|
66
|
+
"algorithms": self.algorithms,
|
|
67
|
+
"options": dict(self.options),
|
|
68
|
+
}
|
|
69
|
+
if self.audience is not None:
|
|
70
|
+
decode_kwargs["audience"] = self.audience
|
|
71
|
+
if self.issuer is not None:
|
|
72
|
+
decode_kwargs["issuer"] = self.issuer
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
payload = pyjwt.decode(token, resolved, **decode_kwargs)
|
|
76
|
+
except pyjwt.ExpiredSignatureError:
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
79
|
+
detail="Token has expired",
|
|
80
|
+
)
|
|
81
|
+
except pyjwt.InvalidTokenError as exc:
|
|
82
|
+
logger.debug("JWT validation failed: %s", exc)
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
85
|
+
detail="Invalid token",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return mapper(payload)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class StaticAuthenticator:
|
|
93
|
+
"""Accept a fixed token value and return preconfigured claims."""
|
|
94
|
+
|
|
95
|
+
token: str
|
|
96
|
+
claims: UserClaims = field(default_factory=lambda: UserClaims(sub="static"))
|
|
97
|
+
|
|
98
|
+
async def __call__(self, request: Request, token: str) -> UserClaims:
|
|
99
|
+
import hmac
|
|
100
|
+
|
|
101
|
+
if not hmac.compare_digest(token, self.token):
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
104
|
+
detail="Invalid token",
|
|
105
|
+
)
|
|
106
|
+
return self.claims
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class RemoteAuthenticator:
|
|
111
|
+
"""Validate the token by calling an upstream HTTP endpoint."""
|
|
112
|
+
|
|
113
|
+
url: str
|
|
114
|
+
method: str = "GET"
|
|
115
|
+
token_header: str = "Authorization"
|
|
116
|
+
token_prefix: str = "Bearer "
|
|
117
|
+
claims_mapper: Callable[[dict[str, Any]], UserClaims] | None = None
|
|
118
|
+
httpx_client_kwargs: dict[str, Any] = field(default_factory=dict)
|
|
119
|
+
client: Any = None
|
|
120
|
+
|
|
121
|
+
async def __call__(self, request: Request, token: str) -> UserClaims:
|
|
122
|
+
import httpx
|
|
123
|
+
|
|
124
|
+
mapper = self.claims_mapper or _default_claims_mapper
|
|
125
|
+
headers = {self.token_header: f"{self.token_prefix}{token}"}
|
|
126
|
+
|
|
127
|
+
async def _do_request(c: httpx.AsyncClient) -> httpx.Response:
|
|
128
|
+
try:
|
|
129
|
+
return await c.request(self.method, self.url, headers=headers)
|
|
130
|
+
except httpx.HTTPError as exc:
|
|
131
|
+
logger.warning("Remote auth request failed: %s", exc)
|
|
132
|
+
raise HTTPException(
|
|
133
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
134
|
+
detail="Authentication service unavailable",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if self.client is not None:
|
|
138
|
+
resp = await _do_request(self.client)
|
|
139
|
+
else:
|
|
140
|
+
client_kwargs = {"timeout": 10.0, **self.httpx_client_kwargs}
|
|
141
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
142
|
+
resp = await _do_request(client)
|
|
143
|
+
|
|
144
|
+
if resp.status_code >= 400:
|
|
145
|
+
logger.debug(
|
|
146
|
+
"Remote auth rejected token: status=%d body=%s",
|
|
147
|
+
resp.status_code,
|
|
148
|
+
resp.text[:200],
|
|
149
|
+
)
|
|
150
|
+
raise HTTPException(
|
|
151
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
152
|
+
detail="Token rejected by authentication service",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
payload = resp.json()
|
|
157
|
+
except Exception:
|
|
158
|
+
raise HTTPException(
|
|
159
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
160
|
+
detail="Invalid response from authentication service",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if isinstance(payload, dict) and payload.get("active") is False:
|
|
164
|
+
raise HTTPException(
|
|
165
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
166
|
+
detail="Token is not active",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return mapper(payload)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class CallbackAuthenticator:
|
|
174
|
+
"""Delegate authentication to an arbitrary user-provided function."""
|
|
175
|
+
|
|
176
|
+
callback: AuthCallback
|
|
177
|
+
|
|
178
|
+
async def __call__(self, request: Request, token: str) -> UserClaims:
|
|
179
|
+
try:
|
|
180
|
+
result = self.callback(request, token)
|
|
181
|
+
if inspect.isawaitable(result):
|
|
182
|
+
result = await result
|
|
183
|
+
except HTTPException:
|
|
184
|
+
raise
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
logger.debug("Callback authenticator failed: %s", exc)
|
|
187
|
+
raise HTTPException(
|
|
188
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
189
|
+
detail="Authentication failed",
|
|
190
|
+
) from exc
|
|
191
|
+
|
|
192
|
+
if not isinstance(result, UserClaims):
|
|
193
|
+
raise TypeError(
|
|
194
|
+
f"Authenticator callback must return UserClaims, got {type(result).__name__}"
|
|
195
|
+
)
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class ChainedAuthenticator:
|
|
201
|
+
"""Try multiple authenticators in order; first success wins."""
|
|
202
|
+
|
|
203
|
+
authenticators: list[Authenticator | Any] = field(default_factory=list)
|
|
204
|
+
|
|
205
|
+
async def __call__(self, request: Request, token: str) -> UserClaims:
|
|
206
|
+
last_exc: HTTPException | None = None
|
|
207
|
+
for auth in self.authenticators:
|
|
208
|
+
try:
|
|
209
|
+
result = auth(request, token)
|
|
210
|
+
if inspect.isawaitable(result):
|
|
211
|
+
result = await result
|
|
212
|
+
return result
|
|
213
|
+
except HTTPException as exc:
|
|
214
|
+
last_exc = exc
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
raise last_exc or HTTPException(
|
|
218
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
219
|
+
detail="All authenticators failed",
|
|
220
|
+
)
|
csrd/auth/_factory.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Dependency factory functions for FastAPI auth integration."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
|
|
9
|
+
from csrd.context.platform import user_info_context
|
|
10
|
+
from csrd.models.claims import UserClaims
|
|
11
|
+
|
|
12
|
+
from ._authenticators import (
|
|
13
|
+
CallbackAuthenticator,
|
|
14
|
+
ChainedAuthenticator,
|
|
15
|
+
JWTAuthenticator,
|
|
16
|
+
RemoteAuthenticator,
|
|
17
|
+
StaticAuthenticator,
|
|
18
|
+
)
|
|
19
|
+
from ._protocols import AuthCallback, Authenticator, KeySource
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_bearer_dependency(
|
|
23
|
+
authenticator: Authenticator | AuthCallback,
|
|
24
|
+
*,
|
|
25
|
+
token_finder: Callable[[], str] | None = None,
|
|
26
|
+
) -> Callable:
|
|
27
|
+
"""Build a FastAPI dependency from any :class:`Authenticator`.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
authenticator:
|
|
32
|
+
The authenticator instance or callback to use.
|
|
33
|
+
token_finder:
|
|
34
|
+
Zero-arg callable that extracts the bearer token from the
|
|
35
|
+
current request context. When *None*, the token must be
|
|
36
|
+
provided by a framework-level mechanism (e.g. the versioning
|
|
37
|
+
dispatch middleware populates the contextvar automatically).
|
|
38
|
+
"""
|
|
39
|
+
if not isinstance(
|
|
40
|
+
authenticator,
|
|
41
|
+
(
|
|
42
|
+
JWTAuthenticator,
|
|
43
|
+
StaticAuthenticator,
|
|
44
|
+
RemoteAuthenticator,
|
|
45
|
+
CallbackAuthenticator,
|
|
46
|
+
ChainedAuthenticator,
|
|
47
|
+
),
|
|
48
|
+
) and callable(authenticator):
|
|
49
|
+
authenticator = CallbackAuthenticator(authenticator)
|
|
50
|
+
|
|
51
|
+
async def _bearer_dependency(request: Request) -> UserClaims:
|
|
52
|
+
if token_finder is None:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"No token_finder configured. Either pass token_finder= to "
|
|
55
|
+
"create_bearer_dependency(), or use csrd.versioning which "
|
|
56
|
+
"provides this automatically."
|
|
57
|
+
)
|
|
58
|
+
token = token_finder()
|
|
59
|
+
|
|
60
|
+
result = authenticator(request, token)
|
|
61
|
+
if inspect.isawaitable(result):
|
|
62
|
+
claims = await result
|
|
63
|
+
else:
|
|
64
|
+
claims = result
|
|
65
|
+
|
|
66
|
+
user_info_context.set(claims)
|
|
67
|
+
return claims
|
|
68
|
+
|
|
69
|
+
return _bearer_dependency
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_jwt_bearer(
|
|
73
|
+
*,
|
|
74
|
+
key: KeySource | None = None,
|
|
75
|
+
algorithms: list[str] | None = None,
|
|
76
|
+
audience: str | None = None,
|
|
77
|
+
issuer: str | None = None,
|
|
78
|
+
options: dict[str, Any] | None = None,
|
|
79
|
+
claims_mapper: Callable[[dict[str, Any]], UserClaims] | None = None,
|
|
80
|
+
token_finder: Callable[[], str] | None = None,
|
|
81
|
+
) -> Callable:
|
|
82
|
+
"""Convenience shortcut: build a JWT bearer dependency."""
|
|
83
|
+
return create_bearer_dependency(
|
|
84
|
+
JWTAuthenticator(
|
|
85
|
+
key=key,
|
|
86
|
+
algorithms=algorithms or ["HS256"],
|
|
87
|
+
audience=audience,
|
|
88
|
+
issuer=issuer,
|
|
89
|
+
options=options or {},
|
|
90
|
+
claims_mapper=claims_mapper,
|
|
91
|
+
),
|
|
92
|
+
token_finder=token_finder,
|
|
93
|
+
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# ruff: noqa: B904
|
|
2
|
+
"""Built-in key provider implementations."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
from pydantic import SecretStr
|
|
12
|
+
from starlette.status import HTTP_401_UNAUTHORIZED
|
|
13
|
+
|
|
14
|
+
from ._protocols import KeyCallback, KeyProvider, KeySource
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class StaticKeyProvider:
|
|
21
|
+
"""A single fixed key — HMAC secret string, PEM-encoded public key, or raw bytes."""
|
|
22
|
+
|
|
23
|
+
key: str | bytes | SecretStr
|
|
24
|
+
|
|
25
|
+
def __call__(self, token_headers: dict[str, Any], app: Any) -> str | bytes:
|
|
26
|
+
if isinstance(self.key, SecretStr):
|
|
27
|
+
return self.key.get_secret_value()
|
|
28
|
+
return self.key
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class EnvKeyProvider:
|
|
33
|
+
"""Read the JWT key from ``app.state`` or a fallback settings loader.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
state_key:
|
|
38
|
+
Attribute name on ``app.state`` where a settings object with a
|
|
39
|
+
``jwt_secret`` field is stored. Defaults to ``"_versioning_settings"``.
|
|
40
|
+
settings_loader:
|
|
41
|
+
Zero-arg callable that returns a settings object with a
|
|
42
|
+
``jwt_secret`` attribute. Called when ``app.state`` lookup fails.
|
|
43
|
+
If *None* and the state lookup also fails, a ``RuntimeError``
|
|
44
|
+
is raised.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
state_key: str = "_versioning_settings"
|
|
48
|
+
settings_loader: Callable[[], Any] | None = None
|
|
49
|
+
|
|
50
|
+
def __call__(self, token_headers: dict[str, Any], app: Any) -> str:
|
|
51
|
+
settings = getattr(app.state, self.state_key, None)
|
|
52
|
+
if settings is None and self.settings_loader is not None:
|
|
53
|
+
settings = self.settings_loader()
|
|
54
|
+
|
|
55
|
+
if settings is None:
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
"No JWT secret configured. Set the JWT_SECRET env var, "
|
|
58
|
+
"pass key= to JWTAuthenticator, or use a KeyProvider."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
jwt_key = getattr(settings, "jwt_secret", None)
|
|
62
|
+
if jwt_key is None:
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
"No JWT secret configured. Set the JWT_SECRET env var, "
|
|
65
|
+
"pass key= to JWTAuthenticator, or use a KeyProvider."
|
|
66
|
+
)
|
|
67
|
+
return jwt_key.get_secret_value() if isinstance(jwt_key, SecretStr) else str(jwt_key)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class JWKSKeyProvider:
|
|
72
|
+
"""Fetch signing keys from a JWKS (JSON Web Key Set) endpoint."""
|
|
73
|
+
|
|
74
|
+
url: str
|
|
75
|
+
cache_ttl: float = 300.0
|
|
76
|
+
httpx_kwargs: dict[str, Any] = field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
_jwk_set: Any = field(default=None, init=False, repr=False)
|
|
79
|
+
_fetched_at: float = field(default=0.0, init=False, repr=False)
|
|
80
|
+
|
|
81
|
+
async def __call__(self, token_headers: dict[str, Any], app: Any) -> Any:
|
|
82
|
+
kid = token_headers.get("kid")
|
|
83
|
+
alg = token_headers.get("alg", "RS256")
|
|
84
|
+
|
|
85
|
+
key = self._find_key(kid, alg)
|
|
86
|
+
if key is not None:
|
|
87
|
+
return key
|
|
88
|
+
|
|
89
|
+
await self._async_refresh()
|
|
90
|
+
key = self._find_key(kid, alg)
|
|
91
|
+
if key is not None:
|
|
92
|
+
return key
|
|
93
|
+
|
|
94
|
+
detail = f"No matching key found in JWKS (kid={kid})" if kid else "No keys in JWKS"
|
|
95
|
+
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=detail)
|
|
96
|
+
|
|
97
|
+
def _find_key(self, kid: str | None, alg: str) -> Any:
|
|
98
|
+
if self._jwk_set is None or (time.monotonic() - self._fetched_at > self.cache_ttl):
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
from jwt import PyJWKSet # noqa: F401 — availability check
|
|
103
|
+
except ImportError:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"JWKSKeyProvider requires PyJWT[crypto]. Install with: pip install 'PyJWT[crypto]'"
|
|
106
|
+
) from None
|
|
107
|
+
|
|
108
|
+
for jwk in self._jwk_set.keys:
|
|
109
|
+
jwk_kid = getattr(jwk, "key_id", None)
|
|
110
|
+
if kid is not None and jwk_kid != kid:
|
|
111
|
+
continue
|
|
112
|
+
return jwk.key
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
async def _async_refresh(self) -> None:
|
|
116
|
+
if self._jwk_set is not None and time.monotonic() - self._fetched_at <= self.cache_ttl:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
import httpx
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
from jwt import PyJWKSet
|
|
123
|
+
except ImportError:
|
|
124
|
+
raise RuntimeError(
|
|
125
|
+
"JWKSKeyProvider requires PyJWT[crypto]. Install with: pip install 'PyJWT[crypto]'"
|
|
126
|
+
) from None
|
|
127
|
+
|
|
128
|
+
kwargs = {"timeout": 10.0, **self.httpx_kwargs}
|
|
129
|
+
try:
|
|
130
|
+
async with httpx.AsyncClient(**kwargs) as client:
|
|
131
|
+
resp = await client.get(self.url)
|
|
132
|
+
resp.raise_for_status()
|
|
133
|
+
self._jwk_set = PyJWKSet.from_dict(resp.json())
|
|
134
|
+
self._fetched_at = time.monotonic()
|
|
135
|
+
logger.debug(
|
|
136
|
+
"Refreshed JWKS from %s: %d key(s)",
|
|
137
|
+
self.url,
|
|
138
|
+
len(self._jwk_set.keys),
|
|
139
|
+
)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
logger.warning("Failed to fetch JWKS from %s: %s", self.url, exc)
|
|
142
|
+
if self._jwk_set is None:
|
|
143
|
+
raise HTTPException(
|
|
144
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
145
|
+
detail="Unable to fetch signing keys",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class MultiKeyProvider:
|
|
151
|
+
"""Route to different keys by ``kid`` JWT header, or try all keys in order."""
|
|
152
|
+
|
|
153
|
+
providers: dict[str, KeyProvider | str | bytes] | list[KeyProvider | str | bytes]
|
|
154
|
+
|
|
155
|
+
def __call__(self, token_headers: dict[str, Any], app: Any) -> Any:
|
|
156
|
+
kid = token_headers.get("kid")
|
|
157
|
+
|
|
158
|
+
if isinstance(self.providers, dict):
|
|
159
|
+
if kid is not None and kid in self.providers:
|
|
160
|
+
return self._resolve_one(self.providers[kid], token_headers, app)
|
|
161
|
+
|
|
162
|
+
for provider in self.providers.values():
|
|
163
|
+
try:
|
|
164
|
+
return self._resolve_one(provider, token_headers, app)
|
|
165
|
+
except Exception:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
raise HTTPException(
|
|
169
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
170
|
+
detail=f"No key found for kid={kid}" if kid else "No matching key",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
last_exc: Exception | None = None
|
|
174
|
+
for provider in self.providers:
|
|
175
|
+
try:
|
|
176
|
+
return self._resolve_one(provider, token_headers, app)
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
last_exc = exc
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
raise last_exc or HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="No matching key")
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _resolve_one(
|
|
185
|
+
provider: KeyProvider | str | bytes, token_headers: dict[str, Any], app: Any
|
|
186
|
+
) -> Any:
|
|
187
|
+
if isinstance(provider, (str, bytes)):
|
|
188
|
+
return provider
|
|
189
|
+
if isinstance(provider, SecretStr):
|
|
190
|
+
return provider.get_secret_value()
|
|
191
|
+
return provider(token_headers, app)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class CallbackKeyProvider:
|
|
196
|
+
"""Delegate key resolution to any user-supplied function."""
|
|
197
|
+
|
|
198
|
+
callback: KeyCallback
|
|
199
|
+
|
|
200
|
+
def __call__(self, token_headers: dict[str, Any], app: Any) -> Any:
|
|
201
|
+
return self.callback(token_headers, app)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── Coerce raw values into KeyProvider instances ────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _coerce_key_provider(key: KeySource | None) -> KeyProvider:
|
|
208
|
+
if key is None:
|
|
209
|
+
return EnvKeyProvider()
|
|
210
|
+
if isinstance(key, KeyProvider):
|
|
211
|
+
return key
|
|
212
|
+
if isinstance(key, (str, bytes, SecretStr)):
|
|
213
|
+
return StaticKeyProvider(key)
|
|
214
|
+
if callable(key):
|
|
215
|
+
return CallbackKeyProvider(key)
|
|
216
|
+
raise TypeError(
|
|
217
|
+
f"key must be a string, bytes, SecretStr, KeyProvider, or callable — "
|
|
218
|
+
f"got {type(key).__name__}"
|
|
219
|
+
)
|
csrd/auth/_protocols.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Protocols and type aliases for the auth system."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from fastapi import Request
|
|
7
|
+
from pydantic import SecretStr
|
|
8
|
+
|
|
9
|
+
from csrd.models.claims import UserClaims
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class Authenticator(Protocol):
|
|
14
|
+
"""Protocol for pluggable authentication strategies."""
|
|
15
|
+
|
|
16
|
+
def __call__(self, request: Request, token: str) -> UserClaims: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class KeyProvider(Protocol):
|
|
21
|
+
"""Protocol for pluggable JWT key resolution."""
|
|
22
|
+
|
|
23
|
+
def __call__(self, token_headers: dict[str, Any], app: Any) -> Any: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
AuthCallback = Callable[[Request, str], UserClaims | Any]
|
|
27
|
+
KeyCallback = Callable[..., Any]
|
|
28
|
+
|
|
29
|
+
KeySource = str | bytes | SecretStr | KeyProvider
|
csrd/auth/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csrd-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pluggable JWT authentication — authenticators, key providers, and FastAPI dependency factories
|
|
5
|
+
Project-URL: Repository, https://github.com/csrd-api/fastapi-common
|
|
6
|
+
Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/auth
|
|
7
|
+
Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: csrd-context
|
|
11
|
+
Requires-Dist: csrd-models
|
|
12
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
13
|
+
Requires-Dist: pydantic<3,>=2
|
|
14
|
+
Requires-Dist: pyjwt<3,>=2
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
csrd/auth/__init__.py,sha256=2_2Ug0hx23XoQmYNs6M2g79uBUS4ck4Rg7DviD0qpfQ,1320
|
|
2
|
+
csrd/auth/_authenticators.py,sha256=C3rRNlr1qoIz8AphoUamuozbB7m5HylyQR_CbpSmXXk,7254
|
|
3
|
+
csrd/auth/_factory.py,sha256=J2mGoyw_JX1C8bxJpvvXULwh5Gin0hTDCv-Ula6cf6E,2801
|
|
4
|
+
csrd/auth/_key_providers.py,sha256=Py7yuHK1WIQh4iO86ScfVIUa1p_5rQDu_Gts3cgOoCo,7512
|
|
5
|
+
csrd/auth/_protocols.py,sha256=Mh68kIwn3EipKhsiBoxfNvZtVlHJcKdKl5hpupJrOIo,756
|
|
6
|
+
csrd/auth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
csrd_auth-0.1.0.dist-info/METADATA,sha256=S1pNBc0p6Dd0eMYrSaW_cfoZmb3U-7Pw6FgW_NXwu6I,594
|
|
8
|
+
csrd_auth-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
csrd_auth-0.1.0.dist-info/RECORD,,
|