fastapi-multiauth 0.4.0__tar.gz → 0.5.0__tar.gz
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-0.4.0 → fastapi_multiauth-0.5.0}/PKG-INFO +3 -2
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/README.md +1 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/pyproject.toml +2 -2
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/pyproject.toml.orig +2 -2
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/__init__.py +7 -1
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/abc.py +95 -19
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/jwt.py +5 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/oauth/client.py +19 -6
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/__init__.py +8 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/basic.py +24 -6
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/bearer.py +42 -26
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/cookie.py +15 -3
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/header.py +3 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/multi.py +55 -12
- fastapi_multiauth-0.5.0/src/fastapi_multiauth/sources/oauth2.py +129 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/query.py +3 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/LICENSE +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/_http.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/_imports.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/exceptions.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/oauth/utils.py +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/py.typed +0 -0
- {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-multiauth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction
|
|
5
5
|
Keywords: fastapi,authentication,security,oauth,api-key,bearer,cookie
|
|
6
6
|
Author: d3vyce
|
|
@@ -25,7 +25,7 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
25
25
|
Classifier: Typing :: Typed
|
|
26
26
|
Requires-Dist: anyio>=3.7
|
|
27
27
|
Requires-Dist: async-lru>=2.0
|
|
28
|
-
Requires-Dist: fastapi>=0.
|
|
28
|
+
Requires-Dist: fastapi>=0.108.0
|
|
29
29
|
Requires-Dist: httpx>=0.25.0
|
|
30
30
|
Requires-Dist: itsdangerous>=2.1
|
|
31
31
|
Requires-Dist: pyjwt[crypto]>=2.13.0 ; extra == 'jwt'
|
|
@@ -122,6 +122,7 @@ async def me(user=Security(auth)):
|
|
|
122
122
|
- **`APIKeyCookieAuth`**: cookie sessions with optional HMAC-SHA256 signing (via [itsdangerous](https://itsdangerous.palletsprojects.com/)), embedded expiry, key rotation, and optional per-session ids so your app can list and revoke sessions individually.
|
|
123
123
|
- **`APIKeyHeaderAuth`**: `X-API-Key`-style schemes, with **`APIKeyQueryAuth`** for legacy clients that can only pass a query parameter.
|
|
124
124
|
- **`HTTPBasicAuth`**: `validator(username, password)` with `WWW-Authenticate` realm support.
|
|
125
|
+
- **`OAuth2AuthorizationCodeBearerAuth`**, **`OAuth2PasswordBearerAuth`**, **`OpenIdConnectAuth`**: validate tokens an OAuth 2.0 / OIDC provider issued, and publish the scope catalogue so `/docs` offers a real Authorize dialog.
|
|
125
126
|
- **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
|
|
126
127
|
- **JWT validation** (`fastapi-multiauth[jwt]` extra): `JWTValidator` for `HTTPBearerAuth`: HS256 or provider JWKS (Keycloak/Auth0/Entra/Authentik) with TTL caching and rotation-aware `kid` refresh, `aud`/`iss`/`exp` checks, configurable scope claims, and a `claims_to_identity` hook.
|
|
127
128
|
- **Security scopes**: `Security(auth, scopes=[...])` forwards the declared scopes to validators that accept a `scopes` parameter.
|
|
@@ -81,6 +81,7 @@ async def me(user=Security(auth)):
|
|
|
81
81
|
- **`APIKeyCookieAuth`**: cookie sessions with optional HMAC-SHA256 signing (via [itsdangerous](https://itsdangerous.palletsprojects.com/)), embedded expiry, key rotation, and optional per-session ids so your app can list and revoke sessions individually.
|
|
82
82
|
- **`APIKeyHeaderAuth`**: `X-API-Key`-style schemes, with **`APIKeyQueryAuth`** for legacy clients that can only pass a query parameter.
|
|
83
83
|
- **`HTTPBasicAuth`**: `validator(username, password)` with `WWW-Authenticate` realm support.
|
|
84
|
+
- **`OAuth2AuthorizationCodeBearerAuth`**, **`OAuth2PasswordBearerAuth`**, **`OpenIdConnectAuth`**: validate tokens an OAuth 2.0 / OIDC provider issued, and publish the scope catalogue so `/docs` offers a real Authorize dialog.
|
|
84
85
|
- **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
|
|
85
86
|
- **JWT validation** (`fastapi-multiauth[jwt]` extra): `JWTValidator` for `HTTPBearerAuth`: HS256 or provider JWKS (Keycloak/Auth0/Entra/Authentik) with TTL caching and rotation-aware `kid` refresh, `aud`/`iss`/`exp` checks, configurable scope claims, and a `claims_to_identity` hook.
|
|
86
87
|
- **Security scopes**: `Security(auth, scopes=[...])` forwards the declared scopes to validators that accept a `scopes` parameter.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -36,7 +36,7 @@ classifiers = [
|
|
|
36
36
|
dependencies = [
|
|
37
37
|
"anyio>=3.7",
|
|
38
38
|
"async-lru>=2.0",
|
|
39
|
-
"fastapi>=0.
|
|
39
|
+
"fastapi>=0.108.0",
|
|
40
40
|
"httpx>=0.25.0",
|
|
41
41
|
"itsdangerous>=2.1",
|
|
42
42
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -31,7 +31,7 @@ classifiers = [
|
|
|
31
31
|
dependencies = [
|
|
32
32
|
"anyio>=3.7",
|
|
33
33
|
"async-lru>=2.0",
|
|
34
|
-
"fastapi>=0.
|
|
34
|
+
"fastapi>=0.108.0",
|
|
35
35
|
"httpx>=0.25.0",
|
|
36
36
|
"itsdangerous>=2.1",
|
|
37
37
|
]
|
|
@@ -20,10 +20,13 @@ from .sources import (
|
|
|
20
20
|
HTTPBasicAuth,
|
|
21
21
|
HTTPBearerAuth,
|
|
22
22
|
MultiAuth,
|
|
23
|
+
OAuth2AuthorizationCodeBearerAuth,
|
|
24
|
+
OAuth2PasswordBearerAuth,
|
|
25
|
+
OpenIdConnectAuth,
|
|
23
26
|
)
|
|
24
27
|
from .utils import hash_token, verify_token_hash
|
|
25
28
|
|
|
26
|
-
__version__ = "0.
|
|
29
|
+
__version__ = "0.5.0"
|
|
27
30
|
|
|
28
31
|
__all__ = [
|
|
29
32
|
"APIKeyCookieAuth",
|
|
@@ -35,6 +38,9 @@ __all__ = [
|
|
|
35
38
|
"HTTPBearerAuth",
|
|
36
39
|
"JWTValidator",
|
|
37
40
|
"MultiAuth",
|
|
41
|
+
"OAuth2AuthorizationCodeBearerAuth",
|
|
42
|
+
"OAuth2PasswordBearerAuth",
|
|
43
|
+
"OpenIdConnectAuth",
|
|
38
44
|
"UnauthorizedError",
|
|
39
45
|
"hash_token",
|
|
40
46
|
"oauth",
|
|
@@ -4,9 +4,9 @@ import copy
|
|
|
4
4
|
import inspect
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
6
|
from collections.abc import Callable
|
|
7
|
-
from typing import TYPE_CHECKING,
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
8
|
|
|
9
|
-
from fastapi import
|
|
9
|
+
from fastapi import HTTPException, Request
|
|
10
10
|
from fastapi.security import SecurityScopes
|
|
11
11
|
from fastapi.security.base import SecurityBase
|
|
12
12
|
|
|
@@ -46,14 +46,45 @@ def _accepts_kwarg(fn: Callable[..., Any], name: str) -> bool:
|
|
|
46
46
|
)
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def _unenforceable_scopes_error(
|
|
50
|
-
|
|
49
|
+
def _unenforceable_scopes_error(
|
|
50
|
+
owner: object, scopes: list[str], reason: str | None = None
|
|
51
|
+
) -> RuntimeError:
|
|
52
|
+
"""Error for scopes declared on a route that this source cannot check.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
owner: The source (or combination) refusing the route.
|
|
56
|
+
scopes: The scopes the route declared.
|
|
57
|
+
reason: Why they cannot be checked, when it is not simply that this
|
|
58
|
+
source's own validator does not accept them.
|
|
59
|
+
"""
|
|
60
|
+
why = reason or "its validator does not declare a 'scopes' parameter"
|
|
51
61
|
return RuntimeError(
|
|
52
62
|
f"{type(owner).__name__} cannot enforce the security scopes "
|
|
53
|
-
f"{scopes!r} declared on this route:
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
63
|
+
f"{scopes!r} declared on this route: {why}. "
|
|
64
|
+
"Add one to the validator (or override authenticate_scoped()), "
|
|
65
|
+
"or remove scopes=... from Security()."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _anonymous_scopes_error(owner: object, scopes: list[str]) -> RuntimeError:
|
|
70
|
+
"""Error for scopes declared on a route that also allows anonymous callers."""
|
|
71
|
+
return RuntimeError(
|
|
72
|
+
f"{type(owner).__name__} is optional() but this route declares the "
|
|
73
|
+
f"security scopes {scopes!r}: an anonymous caller can never satisfy "
|
|
74
|
+
"them, so the route would be gated or not depending on whether a "
|
|
75
|
+
"credential was sent. Drop optional() or drop scopes=... from Security()."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _undeclared_scopes_error(
|
|
80
|
+
owner: object, unknown: list[str], catalogue: dict[str, str]
|
|
81
|
+
) -> RuntimeError:
|
|
82
|
+
"""Error for route scopes missing from the source's published catalogue."""
|
|
83
|
+
known = ", ".join(sorted(catalogue)) or "nothing"
|
|
84
|
+
return RuntimeError(
|
|
85
|
+
f"{type(owner).__name__} does not publish the security scopes "
|
|
86
|
+
f"{unknown!r} declared on this route: its catalogue lists {known}. "
|
|
87
|
+
"Add them to scopes={...} on the source, or correct the route."
|
|
57
88
|
)
|
|
58
89
|
|
|
59
90
|
|
|
@@ -72,6 +103,24 @@ class _DocOnlyScheme(SecurityBase):
|
|
|
72
103
|
return None
|
|
73
104
|
|
|
74
105
|
|
|
106
|
+
_SCHEME_CLASSES: dict[type, type] = {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _carry_scheme(dependency: Any, scheme: SecurityBase) -> None:
|
|
110
|
+
"""Make *dependency* itself the security scheme FastAPI puts in OpenAPI."""
|
|
111
|
+
cls = type(dependency)
|
|
112
|
+
promoted = _SCHEME_CLASSES.get(cls)
|
|
113
|
+
if promoted is None:
|
|
114
|
+
promoted = _SCHEME_CLASSES[cls] = type(
|
|
115
|
+
cls.__name__,
|
|
116
|
+
(cls, SecurityBase),
|
|
117
|
+
{"__module__": cls.__module__, "__doc__": cls.__doc__},
|
|
118
|
+
)
|
|
119
|
+
dependency.__class__ = promoted
|
|
120
|
+
dependency.model = scheme.model
|
|
121
|
+
dependency.scheme_name = scheme.scheme_name
|
|
122
|
+
|
|
123
|
+
|
|
75
124
|
class AuthSource(ABC):
|
|
76
125
|
"""Abstract base class for authentication sources.
|
|
77
126
|
|
|
@@ -81,6 +130,9 @@ class AuthSource(ABC):
|
|
|
81
130
|
"""
|
|
82
131
|
|
|
83
132
|
scheme: SecurityBase | None
|
|
133
|
+
_optional: bool = False
|
|
134
|
+
_scope_catalogue: dict[str, str] | None = None
|
|
135
|
+
"""The scopes this source publishes to OpenAPI, ``None`` when it has none."""
|
|
84
136
|
|
|
85
137
|
def __init__(self, scheme: Any = None) -> None:
|
|
86
138
|
"""Set up the FastAPI dependency signature.
|
|
@@ -90,6 +142,8 @@ class AuthSource(ABC):
|
|
|
90
142
|
metadata is used, extraction always goes through :meth:`extract`.
|
|
91
143
|
"""
|
|
92
144
|
self.scheme = _DocOnlyScheme(scheme) if scheme is not None else None
|
|
145
|
+
if self.scheme is not None:
|
|
146
|
+
_carry_scheme(self, self.scheme)
|
|
93
147
|
|
|
94
148
|
parameters = [
|
|
95
149
|
inspect.Parameter(
|
|
@@ -103,17 +157,6 @@ class AuthSource(ABC):
|
|
|
103
157
|
annotation=SecurityScopes,
|
|
104
158
|
),
|
|
105
159
|
]
|
|
106
|
-
if self.scheme is not None:
|
|
107
|
-
# Declared only so the scheme is registered in OpenAPI; the
|
|
108
|
-
# extracted value is ignored in favor of extract().
|
|
109
|
-
parameters.append(
|
|
110
|
-
inspect.Parameter(
|
|
111
|
-
"credentials",
|
|
112
|
-
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
113
|
-
annotation=Annotated[Any, Depends(self.scheme)],
|
|
114
|
-
default=None,
|
|
115
|
-
)
|
|
116
|
-
)
|
|
117
160
|
self.__signature__ = inspect.Signature(parameters, return_annotation=Any)
|
|
118
161
|
|
|
119
162
|
@abstractmethod
|
|
@@ -141,6 +184,10 @@ class AuthSource(ABC):
|
|
|
141
184
|
raise _unenforceable_scopes_error(self, scopes)
|
|
142
185
|
return await self.authenticate(credential)
|
|
143
186
|
|
|
187
|
+
def _enforces_scopes(self) -> bool:
|
|
188
|
+
"""Whether route-declared scopes reach an actual check in this source."""
|
|
189
|
+
return type(self).authenticate_scoped is not AuthSource.authenticate_scoped
|
|
190
|
+
|
|
144
191
|
async def _authenticate_with_challenge(
|
|
145
192
|
self, credential: str, scopes: list[str]
|
|
146
193
|
) -> Any:
|
|
@@ -151,15 +198,40 @@ class AuthSource(ABC):
|
|
|
151
198
|
add_challenge(exc, self.www_authenticate())
|
|
152
199
|
raise
|
|
153
200
|
|
|
201
|
+
def optional(self) -> "Self":
|
|
202
|
+
"""Return a copy that yields ``None`` instead of 401 when no credential is sent."""
|
|
203
|
+
clone = copy.copy(self)
|
|
204
|
+
clone._optional = True
|
|
205
|
+
return clone
|
|
206
|
+
|
|
207
|
+
def _reject_undeclared_scopes(self, scopes: list[str]) -> None:
|
|
208
|
+
"""Refuse a route declaring scopes this source never published."""
|
|
209
|
+
catalogue = self._scope_catalogue
|
|
210
|
+
if not catalogue:
|
|
211
|
+
return
|
|
212
|
+
unknown = [scope for scope in scopes if scope not in catalogue]
|
|
213
|
+
if unknown:
|
|
214
|
+
raise _undeclared_scopes_error(self, unknown, catalogue)
|
|
215
|
+
|
|
154
216
|
async def dispatch(self, request: Request, scopes: list[str]) -> Any:
|
|
155
217
|
"""Extract the credential, then authenticate it with the route scopes.
|
|
156
218
|
|
|
219
|
+
Returns ``None`` for an absent credential when :meth:`optional` was used.
|
|
220
|
+
|
|
157
221
|
Raises:
|
|
158
222
|
UnauthorizedError: When no credential is present. This source's
|
|
159
223
|
``WWW-Authenticate`` challenge is attached to any 401 raised.
|
|
224
|
+
RuntimeError: When an :meth:`optional` copy meets a route that
|
|
225
|
+
declares scopes, which an anonymous caller could never satisfy.
|
|
160
226
|
"""
|
|
227
|
+
if scopes:
|
|
228
|
+
if self._optional:
|
|
229
|
+
raise _anonymous_scopes_error(self, scopes)
|
|
230
|
+
self._reject_undeclared_scopes(scopes)
|
|
161
231
|
credential = await self.extract(request)
|
|
162
232
|
if credential is None:
|
|
233
|
+
if self._optional:
|
|
234
|
+
return None
|
|
163
235
|
raise UnauthorizedError(headers=challenge_headers(self.www_authenticate()))
|
|
164
236
|
return await self._authenticate_with_challenge(credential, scopes)
|
|
165
237
|
|
|
@@ -215,6 +287,10 @@ class ValidatedAuthSource(AuthSource):
|
|
|
215
287
|
"""Validate a credential, forwarding route-declared scopes to the validator."""
|
|
216
288
|
return await self._call_validator(credential, scopes=scopes)
|
|
217
289
|
|
|
290
|
+
def _enforces_scopes(self) -> bool:
|
|
291
|
+
"""The validator is the authority: every source funnels into it."""
|
|
292
|
+
return self._accepts_scopes
|
|
293
|
+
|
|
218
294
|
def require(self, **kwargs: Any) -> "Self":
|
|
219
295
|
"""Return a copy of this source with additional (or overriding) validator kwargs.
|
|
220
296
|
|
|
@@ -256,5 +256,10 @@ class JWTValidator:
|
|
|
256
256
|
# provider's JWKS must not take down every login.
|
|
257
257
|
continue
|
|
258
258
|
keys[entry.get("kid") or ""] = key
|
|
259
|
+
if not keys:
|
|
260
|
+
raise _JWKSFetchError(
|
|
261
|
+
f"JWKS document has no usable signing key "
|
|
262
|
+
f"({len(document['keys'])} entries)"
|
|
263
|
+
)
|
|
259
264
|
self._keys = keys
|
|
260
265
|
self._fetched_at = time.monotonic()
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import contextlib
|
|
4
4
|
import functools
|
|
5
5
|
from typing import Any, Literal, NamedTuple
|
|
6
|
+
from urllib.parse import urlsplit
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
8
9
|
from async_lru import alru_cache
|
|
@@ -41,13 +42,25 @@ class OIDCEndpoints(NamedTuple):
|
|
|
41
42
|
|
|
42
43
|
def _validate_issuer(discovery_url: str, issuer: Any) -> None:
|
|
43
44
|
"""Check the discovery document claims the issuer it was fetched from."""
|
|
44
|
-
if not
|
|
45
|
-
return
|
|
46
|
-
expected = discovery_url.removesuffix(_DISCOVERY_SUFFIX).rstrip("/")
|
|
47
|
-
if not isinstance(issuer, str) or issuer.rstrip("/") != expected:
|
|
45
|
+
if not isinstance(issuer, str) or not issuer:
|
|
48
46
|
raise OAuthDiscoveryError(
|
|
49
|
-
f"discovery document issuer {issuer!r}
|
|
50
|
-
|
|
47
|
+
f"discovery document has no usable 'issuer' (got {issuer!r})"
|
|
48
|
+
)
|
|
49
|
+
claimed = issuer.rstrip("/")
|
|
50
|
+
parts = urlsplit(discovery_url)
|
|
51
|
+
origin = f"{parts.scheme}://{parts.netloc}"
|
|
52
|
+
path = parts.path.rstrip("/")
|
|
53
|
+
if path.endswith(_DISCOVERY_SUFFIX):
|
|
54
|
+
expected = (origin + path.removesuffix(_DISCOVERY_SUFFIX)).rstrip("/")
|
|
55
|
+
if claimed != expected:
|
|
56
|
+
raise OAuthDiscoveryError(
|
|
57
|
+
f"discovery document issuer {issuer!r} does not match the "
|
|
58
|
+
f"expected issuer {expected!r} derived from the discovery URL"
|
|
59
|
+
)
|
|
60
|
+
elif claimed != origin and not claimed.startswith(f"{origin}/"):
|
|
61
|
+
raise OAuthDiscoveryError(
|
|
62
|
+
f"discovery document issuer {issuer!r} is not served by "
|
|
63
|
+
f"{origin!r}, the origin the document was fetched from"
|
|
51
64
|
)
|
|
52
65
|
|
|
53
66
|
|
{fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/__init__.py
RENAMED
|
@@ -5,6 +5,11 @@ from .bearer import HTTPBearerAuth
|
|
|
5
5
|
from .cookie import APIKeyCookieAuth
|
|
6
6
|
from .header import APIKeyHeaderAuth
|
|
7
7
|
from .multi import MultiAuth
|
|
8
|
+
from .oauth2 import (
|
|
9
|
+
OAuth2AuthorizationCodeBearerAuth,
|
|
10
|
+
OAuth2PasswordBearerAuth,
|
|
11
|
+
OpenIdConnectAuth,
|
|
12
|
+
)
|
|
8
13
|
from .query import APIKeyQueryAuth
|
|
9
14
|
|
|
10
15
|
__all__ = [
|
|
@@ -14,4 +19,7 @@ __all__ = [
|
|
|
14
19
|
"HTTPBasicAuth",
|
|
15
20
|
"HTTPBearerAuth",
|
|
16
21
|
"MultiAuth",
|
|
22
|
+
"OAuth2AuthorizationCodeBearerAuth",
|
|
23
|
+
"OAuth2PasswordBearerAuth",
|
|
24
|
+
"OpenIdConnectAuth",
|
|
17
25
|
]
|
|
@@ -13,6 +13,20 @@ from ..abc import ValidatedAuthSource
|
|
|
13
13
|
from ..utils import authorization_credential
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def _basic_challenge(realm: str | None) -> str:
|
|
17
|
+
"""Build the ``WWW-Authenticate`` challenge, escaping *realm* (RFC 7230 §3.2.6)."""
|
|
18
|
+
if realm is None:
|
|
19
|
+
return "Basic"
|
|
20
|
+
if not (realm.isascii() and realm.isprintable()):
|
|
21
|
+
raise ValueError(
|
|
22
|
+
f"realm must be printable US-ASCII (RFC 7617 §2.1), got {realm!r}: "
|
|
23
|
+
"control characters have no escaped form in a quoted-string, and "
|
|
24
|
+
"non-ASCII cannot be carried in a header value"
|
|
25
|
+
)
|
|
26
|
+
escaped = realm.replace("\\", "\\\\").replace('"', '\\"')
|
|
27
|
+
return f'Basic realm="{escaped}"'
|
|
28
|
+
|
|
29
|
+
|
|
16
30
|
class HTTPBasicAuth(ValidatedAuthSource):
|
|
17
31
|
"""HTTP Basic authentication source (wraps ``HTTPBasic`` for OpenAPI, RFC 7617).
|
|
18
32
|
|
|
@@ -23,8 +37,9 @@ class HTTPBasicAuth(ValidatedAuthSource):
|
|
|
23
37
|
validator: Sync or async callable receiving ``(username, password)`` and
|
|
24
38
|
returning the identity; raises
|
|
25
39
|
:class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
|
|
26
|
-
realm: Optional protection-space name for the ``WWW-Authenticate
|
|
40
|
+
realm: Optional protection-space name for the ``WWW-Authenticate``.
|
|
27
41
|
scheme_name: OpenAPI security scheme name (default ``HTTPBasic``).
|
|
42
|
+
description: Optional prose for the OpenAPI security scheme object.
|
|
28
43
|
**kwargs: Extra keyword arguments forwarded to the validator on every call.
|
|
29
44
|
"""
|
|
30
45
|
|
|
@@ -34,20 +49,23 @@ class HTTPBasicAuth(ValidatedAuthSource):
|
|
|
34
49
|
*,
|
|
35
50
|
realm: str | None = None,
|
|
36
51
|
scheme_name: str | None = None,
|
|
52
|
+
description: str | None = None,
|
|
37
53
|
**kwargs: Any,
|
|
38
54
|
) -> None:
|
|
39
|
-
self.
|
|
55
|
+
self._challenge = _basic_challenge(realm)
|
|
40
56
|
super().__init__(
|
|
41
57
|
validator,
|
|
42
|
-
HTTPBasic(
|
|
58
|
+
HTTPBasic(
|
|
59
|
+
auto_error=False,
|
|
60
|
+
scheme_name=scheme_name,
|
|
61
|
+
description=description,
|
|
62
|
+
),
|
|
43
63
|
**kwargs,
|
|
44
64
|
)
|
|
45
65
|
|
|
46
66
|
def www_authenticate(self) -> str:
|
|
47
67
|
"""``Basic`` challenge, with the realm when configured (RFC 7617 §2)."""
|
|
48
|
-
|
|
49
|
-
return f'Basic realm="{self._realm}"'
|
|
50
|
-
return "Basic"
|
|
68
|
+
return self._challenge
|
|
51
69
|
|
|
52
70
|
async def extract(self, request: Request) -> str | None:
|
|
53
71
|
"""Return the base64 credentials blob, or ``None`` when absent or empty.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Bearer token authentication
|
|
1
|
+
"""Bearer token authentication sources."""
|
|
2
2
|
|
|
3
3
|
import secrets
|
|
4
4
|
from collections.abc import Callable
|
|
@@ -13,7 +13,36 @@ from ..abc import ValidatedAuthSource
|
|
|
13
13
|
from ..utils import authorization_credential
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
class
|
|
16
|
+
class _BearerSource(ValidatedAuthSource):
|
|
17
|
+
"""Extraction shared by every source carrying an ``Authorization: Bearer`` token."""
|
|
18
|
+
|
|
19
|
+
_prefix: str | None = None
|
|
20
|
+
|
|
21
|
+
def www_authenticate(self) -> str:
|
|
22
|
+
"""``Bearer`` challenge per RFC 6750 §3."""
|
|
23
|
+
return "Bearer"
|
|
24
|
+
|
|
25
|
+
async def extract(self, request: Request) -> str | None:
|
|
26
|
+
"""Return the bearer token, or ``None`` when absent, empty, or mismatched.
|
|
27
|
+
|
|
28
|
+
The ``Bearer`` scheme is matched case-insensitively; the prefix, when
|
|
29
|
+
configured, is kept in the returned value.
|
|
30
|
+
"""
|
|
31
|
+
token = authorization_credential(request, "bearer")
|
|
32
|
+
if token is None:
|
|
33
|
+
return None
|
|
34
|
+
if self._prefix is not None and not token.startswith(self._prefix):
|
|
35
|
+
return None
|
|
36
|
+
return token
|
|
37
|
+
|
|
38
|
+
async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
|
|
39
|
+
"""Validate the credential, re-checking the prefix and forwarding scopes."""
|
|
40
|
+
if self._prefix is not None and not credential.startswith(self._prefix):
|
|
41
|
+
raise UnauthorizedError()
|
|
42
|
+
return await self._call_validator(credential, scopes=scopes)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HTTPBearerAuth(_BearerSource):
|
|
17
46
|
"""Bearer token authentication source (wraps ``HTTPBearer`` for OpenAPI).
|
|
18
47
|
|
|
19
48
|
The validator is called as ``await validator(credential, **kwargs)``.
|
|
@@ -25,6 +54,9 @@ class HTTPBearerAuth(ValidatedAuthSource):
|
|
|
25
54
|
prefix are matched, and the prefix is kept in the validated value.
|
|
26
55
|
scheme_name: OpenAPI security scheme name (default ``HTTPBearer``); give
|
|
27
56
|
each source a distinct name when several appear in the same app.
|
|
57
|
+
description: Optional prose for the OpenAPI security scheme object.
|
|
58
|
+
bearer_format: Optional hint at the token format (e.g. ``"JWT"``), for
|
|
59
|
+
documentation only.
|
|
28
60
|
**kwargs: Extra keyword arguments forwarded to the validator on every call.
|
|
29
61
|
"""
|
|
30
62
|
|
|
@@ -34,38 +66,22 @@ class HTTPBearerAuth(ValidatedAuthSource):
|
|
|
34
66
|
*,
|
|
35
67
|
prefix: str | None = None,
|
|
36
68
|
scheme_name: str | None = None,
|
|
69
|
+
description: str | None = None,
|
|
70
|
+
bearer_format: str | None = None,
|
|
37
71
|
**kwargs: Any,
|
|
38
72
|
) -> None:
|
|
39
73
|
self._prefix = prefix
|
|
40
74
|
super().__init__(
|
|
41
75
|
validator,
|
|
42
|
-
HTTPBearer(
|
|
76
|
+
HTTPBearer(
|
|
77
|
+
auto_error=False,
|
|
78
|
+
scheme_name=scheme_name,
|
|
79
|
+
description=description,
|
|
80
|
+
bearerFormat=bearer_format,
|
|
81
|
+
),
|
|
43
82
|
**kwargs,
|
|
44
83
|
)
|
|
45
84
|
|
|
46
|
-
def www_authenticate(self) -> str:
|
|
47
|
-
"""``Bearer`` challenge per RFC 6750 §3."""
|
|
48
|
-
return "Bearer"
|
|
49
|
-
|
|
50
|
-
async def extract(self, request: Request) -> str | None:
|
|
51
|
-
"""Return the bearer token, or ``None`` when absent, empty, or mismatched.
|
|
52
|
-
|
|
53
|
-
The ``Bearer`` scheme is matched case-insensitively; the prefix, when
|
|
54
|
-
configured, is kept in the returned value.
|
|
55
|
-
"""
|
|
56
|
-
token = authorization_credential(request, "bearer")
|
|
57
|
-
if token is None:
|
|
58
|
-
return None
|
|
59
|
-
if self._prefix is not None and not token.startswith(self._prefix):
|
|
60
|
-
return None
|
|
61
|
-
return token
|
|
62
|
-
|
|
63
|
-
async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
|
|
64
|
-
"""Validate the credential, re-checking the prefix and forwarding scopes."""
|
|
65
|
-
if self._prefix is not None and not credential.startswith(self._prefix):
|
|
66
|
-
raise UnauthorizedError()
|
|
67
|
-
return await self._call_validator(credential, scopes=scopes)
|
|
68
|
-
|
|
69
85
|
def generate_token(self, nbytes: int = 32) -> str:
|
|
70
86
|
"""Generate a secure URL-safe random token, prefixed when configured.
|
|
71
87
|
|
|
@@ -36,6 +36,16 @@ def _normalize_secret_keys(secret_key: str | Sequence[str] | None) -> list[str]:
|
|
|
36
36
|
return keys
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def _precomputed_signer(keys: list[str], salt: str) -> TimestampSigner:
|
|
40
|
+
"""A ``TimestampSigner`` deriving its keys once here instead of per ``unsign``."""
|
|
41
|
+
probe = TimestampSigner(keys, salt=salt, digest_method=hashlib.sha256)
|
|
42
|
+
return TimestampSigner(
|
|
43
|
+
[probe.derive_key(key) for key in keys],
|
|
44
|
+
digest_method=hashlib.sha256,
|
|
45
|
+
key_derivation="none",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
39
49
|
class APIKeyCookieAuth(ValidatedAuthSource):
|
|
40
50
|
"""Cookie-based authentication source (wraps ``APIKeyCookie`` for OpenAPI).
|
|
41
51
|
|
|
@@ -60,6 +70,7 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
60
70
|
domain: Optional ``Domain`` cookie attribute.
|
|
61
71
|
path: ``Path`` cookie attribute (default ``"/"``).
|
|
62
72
|
scheme_name: OpenAPI security scheme name (default ``APIKeyCookie_{name}``).
|
|
73
|
+
description: Optional prose for the OpenAPI security scheme object.
|
|
63
74
|
**kwargs: Extra keyword arguments forwarded to the validator on every call.
|
|
64
75
|
"""
|
|
65
76
|
|
|
@@ -76,6 +87,7 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
76
87
|
domain: str | None = None,
|
|
77
88
|
path: str = "/",
|
|
78
89
|
scheme_name: str | None = None,
|
|
90
|
+
description: str | None = None,
|
|
79
91
|
**kwargs: Any,
|
|
80
92
|
) -> None:
|
|
81
93
|
if ttl <= 0:
|
|
@@ -98,10 +110,9 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
98
110
|
# the last one — reverse so the first configured key is the signer.
|
|
99
111
|
# The salt also separates the two payload layouts.
|
|
100
112
|
self._signer = (
|
|
101
|
-
|
|
113
|
+
_precomputed_signer(
|
|
102
114
|
list(reversed(self._secret_keys)),
|
|
103
|
-
|
|
104
|
-
digest_method=hashlib.sha256,
|
|
115
|
+
f"{_SALT_PREFIX}{name}{'.sid' if session_id else ''}",
|
|
105
116
|
)
|
|
106
117
|
if self._secret_keys
|
|
107
118
|
else None
|
|
@@ -117,6 +128,7 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
117
128
|
name=name,
|
|
118
129
|
auto_error=False,
|
|
119
130
|
scheme_name=scheme_name or f"APIKeyCookie_{name}",
|
|
131
|
+
description=description,
|
|
120
132
|
),
|
|
121
133
|
**kwargs,
|
|
122
134
|
)
|
|
@@ -19,6 +19,7 @@ class APIKeyHeaderAuth(ValidatedAuthSource):
|
|
|
19
19
|
validator: Sync or async callable returning the identity; raises
|
|
20
20
|
:class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
|
|
21
21
|
scheme_name: OpenAPI security scheme name (default ``APIKeyHeader_{name}``).
|
|
22
|
+
description: Optional prose for the OpenAPI security scheme object.
|
|
22
23
|
**kwargs: Extra keyword arguments forwarded to the validator on every call.
|
|
23
24
|
"""
|
|
24
25
|
|
|
@@ -28,6 +29,7 @@ class APIKeyHeaderAuth(ValidatedAuthSource):
|
|
|
28
29
|
validator: Callable[..., Any],
|
|
29
30
|
*,
|
|
30
31
|
scheme_name: str | None = None,
|
|
32
|
+
description: str | None = None,
|
|
31
33
|
**kwargs: Any,
|
|
32
34
|
) -> None:
|
|
33
35
|
self._name = name
|
|
@@ -37,6 +39,7 @@ class APIKeyHeaderAuth(ValidatedAuthSource):
|
|
|
37
39
|
name=name,
|
|
38
40
|
auto_error=False,
|
|
39
41
|
scheme_name=scheme_name or f"APIKeyHeader_{name}",
|
|
42
|
+
description=description,
|
|
40
43
|
),
|
|
41
44
|
**kwargs,
|
|
42
45
|
)
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
"""MultiAuth: combine multiple authentication sources into a single callable."""
|
|
2
2
|
|
|
3
|
+
import copy
|
|
3
4
|
import inspect
|
|
4
|
-
from typing import Any, cast
|
|
5
|
+
from typing import Annotated, Any, cast
|
|
5
6
|
|
|
6
|
-
from fastapi import Request
|
|
7
|
+
from fastapi import Depends, Request
|
|
7
8
|
from fastapi.security import SecurityScopes
|
|
8
9
|
|
|
9
10
|
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
10
11
|
|
|
11
|
-
from ..abc import
|
|
12
|
+
from ..abc import (
|
|
13
|
+
AuthSource,
|
|
14
|
+
_anonymous_scopes_error,
|
|
15
|
+
_carry_scheme,
|
|
16
|
+
_unenforceable_scopes_error,
|
|
17
|
+
)
|
|
12
18
|
from ..utils import challenge_headers
|
|
13
19
|
|
|
14
20
|
|
|
@@ -25,6 +31,8 @@ class MultiAuth:
|
|
|
25
31
|
TypeError: If a source is not an :class:`AuthSource` instance.
|
|
26
32
|
"""
|
|
27
33
|
|
|
34
|
+
_optional: bool = False
|
|
35
|
+
|
|
28
36
|
def __init__(self, *sources: AuthSource) -> None:
|
|
29
37
|
for source in sources:
|
|
30
38
|
if not isinstance(source, AuthSource):
|
|
@@ -39,10 +47,6 @@ class MultiAuth:
|
|
|
39
47
|
)
|
|
40
48
|
self._sources = sources
|
|
41
49
|
|
|
42
|
-
# Build a merged signature that includes the security-scheme Depends()
|
|
43
|
-
# parameters from every source so FastAPI registers them in OpenAPI
|
|
44
|
-
# docs. Parameters are renamed `_s{i}_{name}` — unique per source
|
|
45
|
-
# index — so two sources of the same type both keep their scheme.
|
|
46
50
|
merged: list[inspect.Parameter] = [
|
|
47
51
|
inspect.Parameter(
|
|
48
52
|
"request",
|
|
@@ -55,11 +59,20 @@ class MultiAuth:
|
|
|
55
59
|
annotation=SecurityScopes,
|
|
56
60
|
),
|
|
57
61
|
]
|
|
62
|
+
carried = sources[0].scheme if sources else None
|
|
63
|
+
if carried is not None:
|
|
64
|
+
_carry_scheme(self, carried)
|
|
58
65
|
for i, source in enumerate(sources):
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
if source.scheme is None or (i == 0 and carried is not None):
|
|
67
|
+
continue
|
|
68
|
+
merged.append(
|
|
69
|
+
inspect.Parameter(
|
|
70
|
+
f"_s{i}_credentials",
|
|
71
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
72
|
+
annotation=Annotated[Any, Depends(cast(Any, source.scheme))],
|
|
73
|
+
default=None,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
63
76
|
self.__signature__ = inspect.Signature(merged, return_annotation=Any)
|
|
64
77
|
|
|
65
78
|
# The combined challenge is fixed once the sources are known; build it
|
|
@@ -71,16 +84,44 @@ class MultiAuth:
|
|
|
71
84
|
challenges.append(challenge)
|
|
72
85
|
self._www_authenticate = ", ".join(challenges) or None
|
|
73
86
|
|
|
87
|
+
self._unenforceable = tuple(
|
|
88
|
+
type(source).__name__ for source in sources if not source._enforces_scopes()
|
|
89
|
+
)
|
|
90
|
+
self._catalogued = tuple(
|
|
91
|
+
source for source in sources if source._scope_catalogue
|
|
92
|
+
)
|
|
93
|
+
|
|
74
94
|
def www_authenticate(self) -> str | None:
|
|
75
95
|
"""Combined challenge of all sources (RFC 9110 §11.6.1), or ``None``."""
|
|
76
96
|
return self._www_authenticate
|
|
77
97
|
|
|
98
|
+
def optional(self) -> "MultiAuth":
|
|
99
|
+
"""Return a copy that yields ``None`` when no source has a credential."""
|
|
100
|
+
clone = copy.copy(self)
|
|
101
|
+
clone._optional = True
|
|
102
|
+
return clone
|
|
103
|
+
|
|
78
104
|
async def dispatch(self, request: Request, scopes: list[str]) -> Any:
|
|
79
105
|
"""Authenticate with the first source whose credential is present."""
|
|
106
|
+
if scopes:
|
|
107
|
+
if self._optional:
|
|
108
|
+
raise _anonymous_scopes_error(self, scopes)
|
|
109
|
+
for source in self._catalogued:
|
|
110
|
+
source._reject_undeclared_scopes(scopes)
|
|
111
|
+
if self._unenforceable:
|
|
112
|
+
raise _unenforceable_scopes_error(
|
|
113
|
+
self,
|
|
114
|
+
scopes,
|
|
115
|
+
f"{', '.join(self._unenforceable)} cannot check them, so "
|
|
116
|
+
"enforcement would depend on which credential the client "
|
|
117
|
+
"presents",
|
|
118
|
+
)
|
|
80
119
|
for source in self._sources:
|
|
81
120
|
credential = await source.extract(request)
|
|
82
121
|
if credential is not None:
|
|
83
122
|
return await source._authenticate_with_challenge(credential, scopes)
|
|
123
|
+
if self._optional:
|
|
124
|
+
return None
|
|
84
125
|
raise UnauthorizedError(headers=challenge_headers(self.www_authenticate()))
|
|
85
126
|
|
|
86
127
|
async def __call__(self, **kwargs: Any) -> Any:
|
|
@@ -94,4 +135,6 @@ class MultiAuth:
|
|
|
94
135
|
else source
|
|
95
136
|
for source in self._sources
|
|
96
137
|
)
|
|
97
|
-
|
|
138
|
+
clone = MultiAuth(*new_sources)
|
|
139
|
+
clone._optional = self._optional
|
|
140
|
+
return clone
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""OAuth 2.0 and OpenID Connect authentication sources.
|
|
2
|
+
|
|
3
|
+
These validate the bearer token an OAuth 2.0 or OIDC provider already issued.
|
|
4
|
+
They declare no flow of their own: the URLs and scope catalogue they carry are
|
|
5
|
+
OpenAPI metadata, which is what lets ``/docs`` offer a real Authorize dialog
|
|
6
|
+
with per-scope checkboxes. The login flow that mints those tokens lives in
|
|
7
|
+
:mod:`fastapi_multiauth.oauth`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi.security import (
|
|
14
|
+
OAuth2AuthorizationCodeBearer,
|
|
15
|
+
OAuth2PasswordBearer,
|
|
16
|
+
OpenIdConnect,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .bearer import _BearerSource
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OAuth2PasswordBearerAuth(_BearerSource):
|
|
23
|
+
"""Bearer tokens issued by a password-grant token endpoint.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
validator: Sync or async callable returning the identity.
|
|
27
|
+
token_url: URL of the token endpoint, as advertised to OpenAPI.
|
|
28
|
+
scopes: Catalogue of scope names mapped to their descriptions.
|
|
29
|
+
scheme_name: OpenAPI security scheme name.
|
|
30
|
+
description: Optional prose for the OpenAPI security scheme object.
|
|
31
|
+
**kwargs: Extra keyword arguments forwarded to the validator.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
validator: Callable[..., Any],
|
|
37
|
+
*,
|
|
38
|
+
token_url: str,
|
|
39
|
+
scopes: dict[str, str] | None = None,
|
|
40
|
+
scheme_name: str | None = None,
|
|
41
|
+
description: str | None = None,
|
|
42
|
+
**kwargs: Any,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._scope_catalogue = scopes
|
|
45
|
+
super().__init__(
|
|
46
|
+
validator,
|
|
47
|
+
OAuth2PasswordBearer(
|
|
48
|
+
tokenUrl=token_url,
|
|
49
|
+
scopes=scopes,
|
|
50
|
+
scheme_name=scheme_name,
|
|
51
|
+
description=description,
|
|
52
|
+
auto_error=False,
|
|
53
|
+
),
|
|
54
|
+
**kwargs,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OAuth2AuthorizationCodeBearerAuth(_BearerSource):
|
|
59
|
+
"""Bearer tokens issued by an authorization-code flow.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
validator: Sync or async callable returning the identity.
|
|
63
|
+
authorization_url: URL the Authorize dialog sends the user to.
|
|
64
|
+
token_url: URL that exchanges the code for a token.
|
|
65
|
+
refresh_url: Optional refresh endpoint.
|
|
66
|
+
scopes: Catalogue of scope names mapped to their descriptions.
|
|
67
|
+
scheme_name: OpenAPI security scheme name.
|
|
68
|
+
description: Optional prose for the OpenAPI security scheme object.
|
|
69
|
+
**kwargs: Extra keyword arguments forwarded to the validator.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
validator: Callable[..., Any],
|
|
75
|
+
*,
|
|
76
|
+
authorization_url: str,
|
|
77
|
+
token_url: str,
|
|
78
|
+
refresh_url: str | None = None,
|
|
79
|
+
scopes: dict[str, str] | None = None,
|
|
80
|
+
scheme_name: str | None = None,
|
|
81
|
+
description: str | None = None,
|
|
82
|
+
**kwargs: Any,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._scope_catalogue = scopes
|
|
85
|
+
super().__init__(
|
|
86
|
+
validator,
|
|
87
|
+
OAuth2AuthorizationCodeBearer(
|
|
88
|
+
authorizationUrl=authorization_url,
|
|
89
|
+
tokenUrl=token_url,
|
|
90
|
+
refreshUrl=refresh_url,
|
|
91
|
+
scopes=scopes,
|
|
92
|
+
scheme_name=scheme_name,
|
|
93
|
+
description=description,
|
|
94
|
+
auto_error=False,
|
|
95
|
+
),
|
|
96
|
+
**kwargs,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class OpenIdConnectAuth(_BearerSource):
|
|
101
|
+
"""Bearer tokens issued by an OpenID Connect provider.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
validator: Sync or async callable returning the identity.
|
|
105
|
+
openid_connect_url: The provider's ``.well-known`` discovery URL.
|
|
106
|
+
scheme_name: OpenAPI security scheme name.
|
|
107
|
+
description: Optional prose for the OpenAPI security scheme object.
|
|
108
|
+
**kwargs: Extra keyword arguments forwarded to the validator.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
validator: Callable[..., Any],
|
|
114
|
+
*,
|
|
115
|
+
openid_connect_url: str,
|
|
116
|
+
scheme_name: str | None = None,
|
|
117
|
+
description: str | None = None,
|
|
118
|
+
**kwargs: Any,
|
|
119
|
+
) -> None:
|
|
120
|
+
super().__init__(
|
|
121
|
+
validator,
|
|
122
|
+
OpenIdConnect(
|
|
123
|
+
openIdConnectUrl=openid_connect_url,
|
|
124
|
+
scheme_name=scheme_name,
|
|
125
|
+
description=description,
|
|
126
|
+
auto_error=False,
|
|
127
|
+
),
|
|
128
|
+
**kwargs,
|
|
129
|
+
)
|
|
@@ -24,6 +24,7 @@ class APIKeyQueryAuth(ValidatedAuthSource):
|
|
|
24
24
|
validator: Sync or async callable returning the identity; raises
|
|
25
25
|
:class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
|
|
26
26
|
scheme_name: OpenAPI security scheme name (default ``APIKeyQuery_{name}``).
|
|
27
|
+
description: Optional prose for the OpenAPI security scheme object.
|
|
27
28
|
**kwargs: Extra keyword arguments forwarded to the validator on every call.
|
|
28
29
|
"""
|
|
29
30
|
|
|
@@ -33,6 +34,7 @@ class APIKeyQueryAuth(ValidatedAuthSource):
|
|
|
33
34
|
validator: Callable[..., Any],
|
|
34
35
|
*,
|
|
35
36
|
scheme_name: str | None = None,
|
|
37
|
+
description: str | None = None,
|
|
36
38
|
**kwargs: Any,
|
|
37
39
|
) -> None:
|
|
38
40
|
self._name = name
|
|
@@ -42,6 +44,7 @@ class APIKeyQueryAuth(ValidatedAuthSource):
|
|
|
42
44
|
name=name,
|
|
43
45
|
auto_error=False,
|
|
44
46
|
scheme_name=scheme_name or f"APIKeyQuery_{name}",
|
|
47
|
+
description=description,
|
|
45
48
|
),
|
|
46
49
|
**kwargs,
|
|
47
50
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|