fastapi-multiauth 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.
- fastapi_multiauth/__init__.py +42 -0
- fastapi_multiauth/_http.py +44 -0
- fastapi_multiauth/_imports.py +9 -0
- fastapi_multiauth/abc.py +220 -0
- fastapi_multiauth/exceptions.py +66 -0
- fastapi_multiauth/jwt.py +260 -0
- fastapi_multiauth/oauth/__init__.py +43 -0
- fastapi_multiauth/oauth/client.py +246 -0
- fastapi_multiauth/oauth/utils.py +159 -0
- fastapi_multiauth/py.typed +0 -0
- fastapi_multiauth/sources/__init__.py +17 -0
- fastapi_multiauth/sources/basic.py +68 -0
- fastapi_multiauth/sources/bearer.py +80 -0
- fastapi_multiauth/sources/cookie.py +153 -0
- fastapi_multiauth/sources/header.py +45 -0
- fastapi_multiauth/sources/multi.py +97 -0
- fastapi_multiauth/sources/query.py +50 -0
- fastapi_multiauth/utils.py +83 -0
- fastapi_multiauth-0.1.0.dist-info/METADATA +161 -0
- fastapi_multiauth-0.1.0.dist-info/RECORD +22 -0
- fastapi_multiauth-0.1.0.dist-info/WHEEL +4 -0
- fastapi_multiauth-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Composable authentication sources for FastAPI using Security().
|
|
2
|
+
|
|
3
|
+
The OAuth 2.0 / OIDC login-flow helpers live in the :mod:`fastapi_multiauth.oauth`
|
|
4
|
+
namespace — they are a distinct concern from the request-time auth sources
|
|
5
|
+
exported here, and require the ``oauth`` extra. Import them explicitly::
|
|
6
|
+
|
|
7
|
+
from fastapi_multiauth import oauth
|
|
8
|
+
|
|
9
|
+
redirect = oauth.oauth_build_authorization_redirect(...)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from . import oauth
|
|
13
|
+
from .abc import AuthSource
|
|
14
|
+
from .exceptions import ForbiddenError, UnauthorizedError
|
|
15
|
+
from .jwt import JWTValidator
|
|
16
|
+
from .sources import (
|
|
17
|
+
APIKeyCookieAuth,
|
|
18
|
+
APIKeyHeaderAuth,
|
|
19
|
+
APIKeyQueryAuth,
|
|
20
|
+
HTTPBasicAuth,
|
|
21
|
+
HTTPBearerAuth,
|
|
22
|
+
MultiAuth,
|
|
23
|
+
)
|
|
24
|
+
from .utils import hash_token, verify_token_hash
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"APIKeyCookieAuth",
|
|
30
|
+
"APIKeyHeaderAuth",
|
|
31
|
+
"APIKeyQueryAuth",
|
|
32
|
+
"AuthSource",
|
|
33
|
+
"ForbiddenError",
|
|
34
|
+
"HTTPBasicAuth",
|
|
35
|
+
"HTTPBearerAuth",
|
|
36
|
+
"JWTValidator",
|
|
37
|
+
"MultiAuth",
|
|
38
|
+
"UnauthorizedError",
|
|
39
|
+
"hash_token",
|
|
40
|
+
"oauth",
|
|
41
|
+
"verify_token_hash",
|
|
42
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Shared HTTP transport hygiene for the OAuth and JWT modules."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
10
|
+
|
|
11
|
+
DEFAULT_TIMEOUT = 10.0
|
|
12
|
+
"""Default timeout (seconds) applied to every HTTP call made by this library."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _require_https(url: str, description: str) -> str:
|
|
16
|
+
"""Reject non-string URLs and URLs that would send credentials over plaintext HTTP."""
|
|
17
|
+
if not isinstance(url, str):
|
|
18
|
+
raise ValueError(f"{description} must be a string URL (got {url!r})")
|
|
19
|
+
parsed = urlsplit(url)
|
|
20
|
+
if parsed.scheme == "https":
|
|
21
|
+
return url
|
|
22
|
+
if parsed.scheme == "http" and parsed.hostname in _LOOPBACK_HOSTS:
|
|
23
|
+
return url
|
|
24
|
+
raise ValueError(f"{description} must use https:// (got {url!r})")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _get_json(
|
|
28
|
+
url: str,
|
|
29
|
+
*,
|
|
30
|
+
timeout: float,
|
|
31
|
+
error_cls: type[Exception],
|
|
32
|
+
description: str,
|
|
33
|
+
headers: dict[str, str] | None = None,
|
|
34
|
+
) -> Any:
|
|
35
|
+
"""GET *url* and decode the JSON body, mapping failures to *error_cls*."""
|
|
36
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
37
|
+
try:
|
|
38
|
+
resp = await client.get(url, headers=headers)
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
return resp.json()
|
|
41
|
+
except httpx.HTTPError as exc:
|
|
42
|
+
raise error_cls(f"failed to fetch {description}: {exc}") from exc
|
|
43
|
+
except json.JSONDecodeError as exc:
|
|
44
|
+
raise error_cls(f"{description} is not valid JSON") from exc
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Optional dependency helpers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def require_extra(package: str, extra: str) -> None:
|
|
5
|
+
"""Raise *ImportError* with an actionable install instruction."""
|
|
6
|
+
raise ImportError(
|
|
7
|
+
f"'{package}' is required to use this feature. "
|
|
8
|
+
f"Install it with: pip install fastapi-multiauth[{extra}]"
|
|
9
|
+
)
|
fastapi_multiauth/abc.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Abstract base classes for authentication sources."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import inspect
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Annotated, Any, Callable, TypeVar
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, HTTPException, Request
|
|
9
|
+
from fastapi.security import SecurityScopes
|
|
10
|
+
from fastapi.security.base import SecurityBase
|
|
11
|
+
|
|
12
|
+
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
13
|
+
from fastapi_multiauth.utils import add_challenge, challenge_headers, ensure_async
|
|
14
|
+
|
|
15
|
+
_V = TypeVar("_V", bound="ValidatedAuthSource")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _reject_scopes_kwarg(kwargs: dict[str, Any]) -> None:
|
|
19
|
+
"""Reject ``scopes`` as a validator kwarg."""
|
|
20
|
+
if "scopes" in kwargs:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
"'scopes' is a reserved validator kwarg: security scopes declared "
|
|
23
|
+
"on the route via Security(..., scopes=[...]) are injected "
|
|
24
|
+
"automatically. Use a different keyword name."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _accepts_scopes(fn: Callable[..., Any]) -> bool:
|
|
29
|
+
"""Return whether *fn* declares a ``scopes`` parameter."""
|
|
30
|
+
try:
|
|
31
|
+
parameters = inspect.signature(fn).parameters
|
|
32
|
+
except (TypeError, ValueError):
|
|
33
|
+
return False
|
|
34
|
+
param = parameters.get("scopes")
|
|
35
|
+
return param is not None and param.kind in (
|
|
36
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
37
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _unenforceable_scopes_error(owner: object, scopes: list[str]) -> RuntimeError:
|
|
42
|
+
"""Error for scopes declared on a route that this source cannot check."""
|
|
43
|
+
return RuntimeError(
|
|
44
|
+
f"{type(owner).__name__} cannot enforce the security scopes "
|
|
45
|
+
f"{scopes!r} declared on this route: its validator does not "
|
|
46
|
+
"declare a 'scopes' parameter. Add one to the validator (or "
|
|
47
|
+
"override authenticate_scoped()), or remove scopes=... from "
|
|
48
|
+
"Security()."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _scope_kwargs(
|
|
53
|
+
owner: object, accepts_scopes: bool, scopes: list[str]
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
"""Return the ``scopes`` kwarg for a validator, failing closed when unsupported."""
|
|
56
|
+
if accepts_scopes:
|
|
57
|
+
return {"scopes": scopes}
|
|
58
|
+
if scopes:
|
|
59
|
+
raise _unenforceable_scopes_error(owner, scopes)
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _DocOnlyScheme(SecurityBase):
|
|
64
|
+
"""Inert stand-in for a ``fastapi.security`` scheme.
|
|
65
|
+
|
|
66
|
+
Carries the scheme's OpenAPI metadata but never executes its parsing, so
|
|
67
|
+
there is no second authentication path diverging from ``extract()``.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, scheme: Any) -> None:
|
|
71
|
+
self.model = scheme.model
|
|
72
|
+
self.scheme_name = scheme.scheme_name
|
|
73
|
+
|
|
74
|
+
async def __call__(self, request: Request) -> None: # noqa: ARG002
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AuthSource(ABC):
|
|
79
|
+
"""Abstract base class for authentication sources.
|
|
80
|
+
|
|
81
|
+
Subclasses implement :meth:`extract` and :meth:`authenticate`; both
|
|
82
|
+
``Security(source)`` and ``MultiAuth`` route through that pair via
|
|
83
|
+
:meth:`dispatch`.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
scheme: SecurityBase | None
|
|
87
|
+
|
|
88
|
+
def __init__(self, scheme: Any = None) -> None:
|
|
89
|
+
"""Set up the FastAPI dependency signature.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
scheme: Optional ``fastapi.security`` scheme; only its OpenAPI
|
|
93
|
+
metadata is used, extraction always goes through :meth:`extract`.
|
|
94
|
+
"""
|
|
95
|
+
self.scheme = _DocOnlyScheme(scheme) if scheme is not None else None
|
|
96
|
+
|
|
97
|
+
parameters = [
|
|
98
|
+
inspect.Parameter(
|
|
99
|
+
"request",
|
|
100
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
101
|
+
annotation=Request,
|
|
102
|
+
),
|
|
103
|
+
inspect.Parameter(
|
|
104
|
+
"security_scopes",
|
|
105
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
106
|
+
annotation=SecurityScopes,
|
|
107
|
+
),
|
|
108
|
+
]
|
|
109
|
+
if self.scheme is not None:
|
|
110
|
+
# Declared only so the scheme is registered in OpenAPI; the
|
|
111
|
+
# extracted value is ignored in favor of extract().
|
|
112
|
+
parameters.append(
|
|
113
|
+
inspect.Parameter(
|
|
114
|
+
"credentials",
|
|
115
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
116
|
+
annotation=Annotated[Any, Depends(self.scheme)],
|
|
117
|
+
default=None,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
self.__signature__ = inspect.Signature(parameters, return_annotation=Any)
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
async def extract(self, request: Request) -> str | None:
|
|
124
|
+
"""Extract the raw credential from the request without validating.
|
|
125
|
+
|
|
126
|
+
Must return ``None`` — never an empty string — when the credential is
|
|
127
|
+
absent, empty, or does not belong to this source.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
async def authenticate(self, credential: str) -> Any:
|
|
132
|
+
"""Validate a credential and return the authenticated identity."""
|
|
133
|
+
|
|
134
|
+
def www_authenticate(self) -> str | None:
|
|
135
|
+
"""Challenge value for the ``WWW-Authenticate`` header on 401 responses.
|
|
136
|
+
|
|
137
|
+
Returns ``None`` when the source has no HTTP auth scheme (cookies, API keys).
|
|
138
|
+
"""
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
|
|
142
|
+
"""Validate a credential, enforcing the scopes declared on the route."""
|
|
143
|
+
if scopes: # fail closed: plain authenticate() cannot check scopes
|
|
144
|
+
raise _unenforceable_scopes_error(self, scopes)
|
|
145
|
+
return await self.authenticate(credential)
|
|
146
|
+
|
|
147
|
+
async def _authenticate_with_challenge(
|
|
148
|
+
self, credential: str, scopes: list[str]
|
|
149
|
+
) -> Any:
|
|
150
|
+
"""Authenticate, attaching this source's challenge to any 401 raised."""
|
|
151
|
+
try:
|
|
152
|
+
return await self.authenticate_scoped(credential, scopes)
|
|
153
|
+
except HTTPException as exc:
|
|
154
|
+
add_challenge(exc, self.www_authenticate())
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
async def dispatch(self, request: Request, scopes: list[str]) -> Any:
|
|
158
|
+
"""Extract the credential, then authenticate it with the route scopes.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
UnauthorizedError: When no credential is present. This source's
|
|
162
|
+
``WWW-Authenticate`` challenge is attached to any 401 raised.
|
|
163
|
+
"""
|
|
164
|
+
credential = await self.extract(request)
|
|
165
|
+
if credential is None:
|
|
166
|
+
raise UnauthorizedError(headers=challenge_headers(self.www_authenticate()))
|
|
167
|
+
return await self._authenticate_with_challenge(credential, scopes)
|
|
168
|
+
|
|
169
|
+
async def __call__(self, **kwargs: Any) -> Any:
|
|
170
|
+
"""FastAPI dependency dispatch."""
|
|
171
|
+
return await self.dispatch(kwargs["request"], kwargs["security_scopes"].scopes)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ValidatedAuthSource(AuthSource):
|
|
175
|
+
"""Base for sources whose credential is checked by a user-supplied validator.
|
|
176
|
+
|
|
177
|
+
Owns the shared validator plumbing (sync/async normalization, scope and
|
|
178
|
+
kwargs forwarding, :meth:`require`). Subclasses implement :meth:`extract`.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
validator: Callable[..., Any],
|
|
184
|
+
scheme: Any = None,
|
|
185
|
+
/,
|
|
186
|
+
**kwargs: Any,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Bind the validator and its forwarded kwargs.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
validator: Sync or async callable returning the identity.
|
|
192
|
+
scheme: Optional ``fastapi.security`` scheme for OpenAPI.
|
|
193
|
+
**kwargs: Extra keyword arguments forwarded to the validator on
|
|
194
|
+
every call. ``scopes`` is reserved (injected from the route).
|
|
195
|
+
"""
|
|
196
|
+
_reject_scopes_kwarg(kwargs)
|
|
197
|
+
self._validator = ensure_async(validator)
|
|
198
|
+
self._accepts_scopes = _accepts_scopes(validator)
|
|
199
|
+
self._kwargs = kwargs
|
|
200
|
+
super().__init__(scheme)
|
|
201
|
+
|
|
202
|
+
async def _call_validator(self, *args: Any, scopes: list[str]) -> Any:
|
|
203
|
+
"""Invoke the validator with scope and configured kwargs forwarding."""
|
|
204
|
+
extra = _scope_kwargs(self, self._accepts_scopes, scopes)
|
|
205
|
+
return await self._validator(*args, **extra, **self._kwargs)
|
|
206
|
+
|
|
207
|
+
async def authenticate(self, credential: str) -> Any:
|
|
208
|
+
"""Validate a credential and return the identity (no route scopes)."""
|
|
209
|
+
return await self.authenticate_scoped(credential, [])
|
|
210
|
+
|
|
211
|
+
async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
|
|
212
|
+
"""Validate a credential, forwarding route-declared scopes to the validator."""
|
|
213
|
+
return await self._call_validator(credential, scopes=scopes)
|
|
214
|
+
|
|
215
|
+
def require(self: _V, **kwargs: Any) -> _V:
|
|
216
|
+
"""Return a copy of this source with additional (or overriding) validator kwargs."""
|
|
217
|
+
_reject_scopes_kwarg(kwargs)
|
|
218
|
+
clone = copy.copy(self)
|
|
219
|
+
clone._kwargs = {**self._kwargs, **kwargs}
|
|
220
|
+
return clone
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Exceptions raised by authentication sources and OAuth helpers."""
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException, status
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UnauthorizedError(HTTPException):
|
|
7
|
+
"""HTTP 401 — authentication credentials were missing or invalid.
|
|
8
|
+
|
|
9
|
+
Raise from a validator (or any ``HTTPException``) to reject a credential.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
detail: str = "Unauthorized",
|
|
15
|
+
headers: dict[str, str] | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Initialize the exception.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
detail: Human-readable message returned in the response body.
|
|
21
|
+
headers: Extra response headers (e.g. ``WWW-Authenticate``).
|
|
22
|
+
"""
|
|
23
|
+
super().__init__(
|
|
24
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail=detail, headers=headers
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ForbiddenError(HTTPException):
|
|
29
|
+
"""HTTP 403 — the identity is authenticated but lacks permission.
|
|
30
|
+
|
|
31
|
+
Keep 401 (:class:`UnauthorizedError`) for absent or invalid credentials.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
detail: str = "Forbidden",
|
|
37
|
+
headers: dict[str, str] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Initialize the exception.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
detail: Human-readable message returned in the response body.
|
|
43
|
+
headers: Extra response headers.
|
|
44
|
+
"""
|
|
45
|
+
super().__init__(
|
|
46
|
+
status_code=status.HTTP_403_FORBIDDEN, detail=detail, headers=headers
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OAuthError(Exception):
|
|
51
|
+
"""Base class for OAuth flow errors.
|
|
52
|
+
|
|
53
|
+
Not an ``HTTPException``: the route decides the HTTP outcome of a failed flow.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OAuthDiscoveryError(OAuthError):
|
|
58
|
+
"""Raised when the OIDC discovery document cannot be fetched or is invalid."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OAuthExchangeError(OAuthError):
|
|
62
|
+
"""Raised when the OAuth authorization code exchange fails."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OAuthUserinfoError(OAuthError):
|
|
66
|
+
"""Raised when the userinfo endpoint cannot be reached or returns garbage."""
|
fastapi_multiauth/jwt.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""JWT validation built on PyJWT, for use as a ``HTTPBearerAuth`` validator."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
from fastapi import HTTPException, status
|
|
10
|
+
|
|
11
|
+
from ._http import DEFAULT_TIMEOUT, _get_json, _require_https
|
|
12
|
+
from ._imports import require_extra
|
|
13
|
+
from .exceptions import ForbiddenError, UnauthorizedError
|
|
14
|
+
from .utils import ensure_async
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("fastapi_multiauth.jwt")
|
|
17
|
+
|
|
18
|
+
_MIN_SECRET_BYTES = 32
|
|
19
|
+
|
|
20
|
+
# Inferred "alg" for JWKS entries that omit it (e.g. some Entra/ADFS setups).
|
|
21
|
+
_KTY_DEFAULT_ALG = {"RSA": "RS256"}
|
|
22
|
+
_CRV_DEFAULT_ALG = {"P-256": "ES256", "P-384": "ES384", "P-521": "ES512"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _JWKSFetchError(Exception):
|
|
26
|
+
"""JWKS endpoint unreachable or returned an unusable document."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _import_pyjwt() -> Any:
|
|
30
|
+
try:
|
|
31
|
+
import jwt as pyjwt
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
require_extra("pyjwt", "jwt")
|
|
34
|
+
return pyjwt
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _default_alg(entry: dict[str, Any]) -> str | None:
|
|
38
|
+
"""Best-effort default algorithm for a JWKS entry without ``alg``."""
|
|
39
|
+
kty = entry.get("kty")
|
|
40
|
+
if kty == "EC":
|
|
41
|
+
return _CRV_DEFAULT_ALG.get(entry.get("crv", ""))
|
|
42
|
+
return _KTY_DEFAULT_ALG.get(kty or "")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class JWTValidator:
|
|
46
|
+
"""Validate JWTs — plug directly into :class:`~fastapi_multiauth.HTTPBearerAuth`.
|
|
47
|
+
|
|
48
|
+
Requires the ``jwt`` extra. Configure exactly one of ``secret`` (symmetric
|
|
49
|
+
``HS*``) or ``jwks_url`` (asymmetric, against the provider's JWKS).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
secret: Symmetric key for ``HS*`` validation (at least 32 bytes).
|
|
53
|
+
jwks_url: HTTPS URL of the provider's JWKS document.
|
|
54
|
+
algorithms: Accepted signature algorithms. Defaults to ``["HS256"]``
|
|
55
|
+
in secret mode and ``["RS256", "ES256"]`` in JWKS mode. Symmetric
|
|
56
|
+
algorithms are rejected in JWKS mode (key-confusion risk).
|
|
57
|
+
audience: Expected ``aud`` claim value(s). Unset → ``aud`` not checked.
|
|
58
|
+
issuer: Expected ``iss`` claim value. Unset → ``iss`` not checked.
|
|
59
|
+
leeway: Clock-skew tolerance in seconds for time-based claims.
|
|
60
|
+
required_claims: Claims that must be present (default ``("exp",)``).
|
|
61
|
+
scopes_claim: Claim holding granted scopes — a space-separated string
|
|
62
|
+
(``scope``) or a list (``scp``, ``roles``). A valid token missing
|
|
63
|
+
a route-declared scope raises a 403.
|
|
64
|
+
claims_to_identity: Optional sync or async hook mapping verified claims
|
|
65
|
+
to a domain object. Defaults to returning the claims.
|
|
66
|
+
jwks_cache_ttl: JWKS cache lifetime in seconds (default 1 h).
|
|
67
|
+
jwks_refresh_cooldown: Minimum interval in seconds between JWKS fetch
|
|
68
|
+
attempts (default 30 s).
|
|
69
|
+
timeout: Timeout in seconds for the JWKS request.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
secret: str | None = None,
|
|
76
|
+
jwks_url: str | None = None,
|
|
77
|
+
algorithms: Sequence[str] | None = None,
|
|
78
|
+
audience: str | Sequence[str] | None = None,
|
|
79
|
+
issuer: str | None = None,
|
|
80
|
+
leeway: float = 0.0,
|
|
81
|
+
required_claims: Sequence[str] = ("exp",),
|
|
82
|
+
scopes_claim: str = "scope",
|
|
83
|
+
claims_to_identity: Callable[[dict[str, Any]], Any] | None = None,
|
|
84
|
+
jwks_cache_ttl: float = 3600.0,
|
|
85
|
+
jwks_refresh_cooldown: float = 30.0,
|
|
86
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
87
|
+
) -> None:
|
|
88
|
+
self._jwt = _import_pyjwt()
|
|
89
|
+
if (secret is None) == (jwks_url is None):
|
|
90
|
+
raise ValueError("provide exactly one of 'secret' or 'jwks_url'")
|
|
91
|
+
if secret is not None:
|
|
92
|
+
if len(secret.encode()) < _MIN_SECRET_BYTES:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"secret must be at least {_MIN_SECRET_BYTES} bytes of "
|
|
95
|
+
"high-entropy material (e.g. secrets.token_urlsafe(32))"
|
|
96
|
+
)
|
|
97
|
+
self._algorithms = list(algorithms or ["HS256"])
|
|
98
|
+
else:
|
|
99
|
+
_require_https(jwks_url or "", "jwks_url")
|
|
100
|
+
self._algorithms = list(algorithms or ["RS256", "ES256"])
|
|
101
|
+
if any(alg.upper().startswith("HS") for alg in self._algorithms):
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"symmetric (HS*) algorithms cannot be used with jwks_url: "
|
|
104
|
+
"a public JWKS key must never double as an HMAC secret "
|
|
105
|
+
"(key-confusion attack)"
|
|
106
|
+
)
|
|
107
|
+
self._secret = secret
|
|
108
|
+
self._jwks_url = jwks_url
|
|
109
|
+
self._audience = audience
|
|
110
|
+
self._issuer = issuer
|
|
111
|
+
self._leeway = leeway
|
|
112
|
+
self._decode_options = {
|
|
113
|
+
"require": list(required_claims),
|
|
114
|
+
"verify_aud": audience is not None,
|
|
115
|
+
"verify_iss": issuer is not None,
|
|
116
|
+
}
|
|
117
|
+
self._scopes_claim = scopes_claim
|
|
118
|
+
self._claims_to_identity = (
|
|
119
|
+
ensure_async(claims_to_identity) if claims_to_identity else None
|
|
120
|
+
)
|
|
121
|
+
self._jwks_cache_ttl = jwks_cache_ttl
|
|
122
|
+
self._jwks_refresh_cooldown = jwks_refresh_cooldown
|
|
123
|
+
self._timeout = timeout
|
|
124
|
+
self._keys: dict[str, Any] = {}
|
|
125
|
+
self._fetched_at = 0.0
|
|
126
|
+
self._attempted_at = float("-inf")
|
|
127
|
+
self._refresh_lock = anyio.Lock()
|
|
128
|
+
|
|
129
|
+
async def __call__(self, token: str, scopes: list[str] | None = None) -> Any:
|
|
130
|
+
"""Verify *token* and return the identity.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
UnauthorizedError: Invalid/expired signature or claims (401).
|
|
134
|
+
ForbiddenError: Valid token missing route-required scopes (403).
|
|
135
|
+
HTTPException: 503 when the JWKS cannot be fetched and no keys
|
|
136
|
+
are cached — the token cannot be checked either way.
|
|
137
|
+
"""
|
|
138
|
+
if self._secret is not None:
|
|
139
|
+
key: Any = self._secret
|
|
140
|
+
else:
|
|
141
|
+
key = (await self._resolve_jwks_key(token)).key
|
|
142
|
+
try:
|
|
143
|
+
claims = self._jwt.decode(
|
|
144
|
+
token,
|
|
145
|
+
key=key,
|
|
146
|
+
algorithms=self._algorithms,
|
|
147
|
+
audience=self._audience,
|
|
148
|
+
issuer=self._issuer,
|
|
149
|
+
leeway=self._leeway,
|
|
150
|
+
options=self._decode_options,
|
|
151
|
+
)
|
|
152
|
+
except self._jwt.InvalidTokenError:
|
|
153
|
+
raise UnauthorizedError() from None
|
|
154
|
+
if scopes:
|
|
155
|
+
missing = set(scopes) - self._granted_scopes(claims)
|
|
156
|
+
if missing:
|
|
157
|
+
raise ForbiddenError("Insufficient scope")
|
|
158
|
+
if self._claims_to_identity is not None:
|
|
159
|
+
return await self._claims_to_identity(claims)
|
|
160
|
+
return claims
|
|
161
|
+
|
|
162
|
+
def _granted_scopes(self, claims: dict[str, Any]) -> set[str]:
|
|
163
|
+
"""Read the configured scopes claim: space-separated string or list."""
|
|
164
|
+
value = claims.get(self._scopes_claim)
|
|
165
|
+
if isinstance(value, str):
|
|
166
|
+
return set(value.split())
|
|
167
|
+
if isinstance(value, (list, tuple)):
|
|
168
|
+
return {str(item) for item in value}
|
|
169
|
+
return set()
|
|
170
|
+
|
|
171
|
+
def _lookup_key(self, kid: str | None) -> Any | None:
|
|
172
|
+
if kid is not None:
|
|
173
|
+
return self._keys.get(kid)
|
|
174
|
+
if len(self._keys) == 1:
|
|
175
|
+
return next(iter(self._keys.values()))
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def _cache_stale(self) -> bool:
|
|
179
|
+
return (
|
|
180
|
+
not self._keys or time.monotonic() - self._fetched_at > self._jwks_cache_ttl
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def _resolve_jwks_key(self, token: str) -> Any:
|
|
184
|
+
"""Resolve the verification key for *token* from the cached JWKS."""
|
|
185
|
+
try:
|
|
186
|
+
header = self._jwt.get_unverified_header(token)
|
|
187
|
+
except self._jwt.InvalidTokenError:
|
|
188
|
+
raise UnauthorizedError() from None
|
|
189
|
+
kid = header.get("kid")
|
|
190
|
+
if self._cache_stale():
|
|
191
|
+
await self._refresh_jwks()
|
|
192
|
+
key = self._lookup_key(kid)
|
|
193
|
+
if key is None:
|
|
194
|
+
# Unknown kid: the provider may have rotated keys. The refresh is
|
|
195
|
+
# rate-limited so unauthenticated junk can't force fetch storms.
|
|
196
|
+
await self._refresh_jwks(force=True)
|
|
197
|
+
key = self._lookup_key(kid)
|
|
198
|
+
if key is None:
|
|
199
|
+
raise UnauthorizedError()
|
|
200
|
+
return key
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _unavailable() -> HTTPException:
|
|
204
|
+
return HTTPException(
|
|
205
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
206
|
+
detail="Authentication temporarily unavailable",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
async def _refresh_jwks(self, force: bool = False) -> None:
|
|
210
|
+
"""Refetch the JWKS, coalescing concurrent and repeated refreshes.
|
|
211
|
+
|
|
212
|
+
At most one attempt per ``jwks_refresh_cooldown``; a failed fetch falls
|
|
213
|
+
back on cached keys, or surfaces as a 503 when the cache is empty.
|
|
214
|
+
"""
|
|
215
|
+
async with self._refresh_lock:
|
|
216
|
+
# Re-check after acquiring: another request may just have tried.
|
|
217
|
+
if time.monotonic() - self._attempted_at < self._jwks_refresh_cooldown:
|
|
218
|
+
if not self._keys:
|
|
219
|
+
raise self._unavailable()
|
|
220
|
+
return
|
|
221
|
+
if not force and not self._cache_stale():
|
|
222
|
+
return
|
|
223
|
+
self._attempted_at = time.monotonic()
|
|
224
|
+
try:
|
|
225
|
+
await self._fetch_jwks()
|
|
226
|
+
except _JWKSFetchError as exc:
|
|
227
|
+
logger.warning("JWKS refresh failed: %s", exc)
|
|
228
|
+
if not self._keys:
|
|
229
|
+
raise self._unavailable() from exc
|
|
230
|
+
# Stale fallback: keep validating with the cached keys.
|
|
231
|
+
|
|
232
|
+
async def _fetch_jwks(self) -> None:
|
|
233
|
+
document = await _get_json(
|
|
234
|
+
self._jwks_url or "",
|
|
235
|
+
timeout=self._timeout,
|
|
236
|
+
error_cls=_JWKSFetchError,
|
|
237
|
+
description="JWKS document",
|
|
238
|
+
)
|
|
239
|
+
if not isinstance(document, dict) or not isinstance(document.get("keys"), list):
|
|
240
|
+
raise _JWKSFetchError("JWKS document has no 'keys' list")
|
|
241
|
+
keys: dict[str, Any] = {}
|
|
242
|
+
for entry in document["keys"]:
|
|
243
|
+
if not isinstance(entry, dict):
|
|
244
|
+
continue
|
|
245
|
+
if entry.get("use") not in (None, "sig"):
|
|
246
|
+
continue
|
|
247
|
+
if "alg" not in entry:
|
|
248
|
+
alg = _default_alg(entry)
|
|
249
|
+
if alg is None:
|
|
250
|
+
continue
|
|
251
|
+
entry = {**entry, "alg": alg}
|
|
252
|
+
try:
|
|
253
|
+
key = self._jwt.PyJWK(entry)
|
|
254
|
+
except (self._jwt.exceptions.PyJWTError, KeyError):
|
|
255
|
+
# Unsupported or malformed entry: one broken key in the
|
|
256
|
+
# provider's JWKS must not take down every login.
|
|
257
|
+
continue
|
|
258
|
+
keys[entry.get("kid") or ""] = key
|
|
259
|
+
self._keys = keys
|
|
260
|
+
self._fetched_at = time.monotonic()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""OAuth 2.0 / OIDC login-flow helpers (the ``oauth`` extra).
|
|
2
|
+
|
|
3
|
+
These obtain a credential through the browser authorization-code flow — a
|
|
4
|
+
distinct concern from the request-time auth sources in the top-level package.
|
|
5
|
+
``client`` holds the network operations (discovery, code exchange, userinfo);
|
|
6
|
+
``utils`` holds the pure helpers (state, PKCE, the authorization redirect).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ..exceptions import (
|
|
10
|
+
OAuthDiscoveryError,
|
|
11
|
+
OAuthError,
|
|
12
|
+
OAuthExchangeError,
|
|
13
|
+
OAuthUserinfoError,
|
|
14
|
+
)
|
|
15
|
+
from .client import (
|
|
16
|
+
OIDCEndpoints,
|
|
17
|
+
oauth_exchange_code,
|
|
18
|
+
oauth_fetch_userinfo,
|
|
19
|
+
oauth_resolve_provider_urls,
|
|
20
|
+
)
|
|
21
|
+
from .utils import (
|
|
22
|
+
oauth_build_authorization_redirect,
|
|
23
|
+
oauth_decode_state,
|
|
24
|
+
oauth_encode_state,
|
|
25
|
+
oauth_generate_pkce_pair,
|
|
26
|
+
oauth_generate_state_token,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"OAuthDiscoveryError",
|
|
31
|
+
"OAuthError",
|
|
32
|
+
"OAuthExchangeError",
|
|
33
|
+
"OAuthUserinfoError",
|
|
34
|
+
"OIDCEndpoints",
|
|
35
|
+
"oauth_build_authorization_redirect",
|
|
36
|
+
"oauth_decode_state",
|
|
37
|
+
"oauth_encode_state",
|
|
38
|
+
"oauth_exchange_code",
|
|
39
|
+
"oauth_fetch_userinfo",
|
|
40
|
+
"oauth_generate_pkce_pair",
|
|
41
|
+
"oauth_generate_state_token",
|
|
42
|
+
"oauth_resolve_provider_urls",
|
|
43
|
+
]
|