fastapi-multiauth 0.1.0__tar.gz → 0.3.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.1.0 → fastapi_multiauth-0.3.0}/PKG-INFO +12 -3
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/README.md +10 -1
- fastapi_multiauth-0.3.0/pyproject.toml +106 -0
- fastapi_multiauth-0.1.0/pyproject.toml → fastapi_multiauth-0.3.0/pyproject.toml.orig +4 -4
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/__init__.py +1 -1
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/_http.py +9 -3
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/abc.py +6 -4
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/jwt.py +3 -3
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/oauth/client.py +22 -4
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/oauth/utils.py +10 -4
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/basic.py +2 -1
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/bearer.py +2 -1
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/cookie.py +2 -2
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/header.py +2 -1
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/query.py +2 -1
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/utils.py +4 -2
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/LICENSE +0 -0
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/_imports.py +0 -0
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/exceptions.py +0 -0
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/py.typed +0 -0
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
- {fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/multi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-multiauth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -28,7 +28,7 @@ Requires-Dist: async-lru>=2.0
|
|
|
28
28
|
Requires-Dist: fastapi>=0.100.0
|
|
29
29
|
Requires-Dist: httpx>=0.25.0
|
|
30
30
|
Requires-Dist: itsdangerous>=2.1
|
|
31
|
-
Requires-Dist: pyjwt[crypto]>=2.
|
|
31
|
+
Requires-Dist: pyjwt[crypto]>=2.13.0 ; extra == 'jwt'
|
|
32
32
|
Requires-Dist: httpx-oauth>=0.16 ; extra == 'oauth'
|
|
33
33
|
Requires-Python: >=3.10
|
|
34
34
|
Project-URL: Homepage, https://github.com/d3vyce/fastapi-multiauth
|
|
@@ -79,7 +79,13 @@ uv add "fastapi-multiauth[jwt,oauth]"
|
|
|
79
79
|
|
|
80
80
|
```python
|
|
81
81
|
from fastapi import FastAPI, Security
|
|
82
|
-
from fastapi_multiauth import
|
|
82
|
+
from fastapi_multiauth import (
|
|
83
|
+
HTTPBearerAuth,
|
|
84
|
+
APIKeyCookieAuth,
|
|
85
|
+
MultiAuth,
|
|
86
|
+
UnauthorizedError,
|
|
87
|
+
)
|
|
88
|
+
|
|
83
89
|
|
|
84
90
|
async def validate_token(token: str) -> dict:
|
|
85
91
|
user = await lookup_user_by_token(token)
|
|
@@ -87,9 +93,11 @@ async def validate_token(token: str) -> dict:
|
|
|
87
93
|
raise UnauthorizedError()
|
|
88
94
|
return user
|
|
89
95
|
|
|
96
|
+
|
|
90
97
|
async def validate_session(value: str) -> dict:
|
|
91
98
|
return await lookup_user_by_session(value)
|
|
92
99
|
|
|
100
|
+
|
|
93
101
|
bearer = HTTPBearerAuth(validate_token, prefix="user_")
|
|
94
102
|
session = APIKeyCookieAuth("session", validate_session, secret_key="...")
|
|
95
103
|
|
|
@@ -98,6 +106,7 @@ auth = MultiAuth(bearer, session)
|
|
|
98
106
|
|
|
99
107
|
app = FastAPI()
|
|
100
108
|
|
|
109
|
+
|
|
101
110
|
@app.get("/me")
|
|
102
111
|
async def me(user=Security(auth)):
|
|
103
112
|
return user
|
|
@@ -38,7 +38,13 @@ uv add "fastapi-multiauth[jwt,oauth]"
|
|
|
38
38
|
|
|
39
39
|
```python
|
|
40
40
|
from fastapi import FastAPI, Security
|
|
41
|
-
from fastapi_multiauth import
|
|
41
|
+
from fastapi_multiauth import (
|
|
42
|
+
HTTPBearerAuth,
|
|
43
|
+
APIKeyCookieAuth,
|
|
44
|
+
MultiAuth,
|
|
45
|
+
UnauthorizedError,
|
|
46
|
+
)
|
|
47
|
+
|
|
42
48
|
|
|
43
49
|
async def validate_token(token: str) -> dict:
|
|
44
50
|
user = await lookup_user_by_token(token)
|
|
@@ -46,9 +52,11 @@ async def validate_token(token: str) -> dict:
|
|
|
46
52
|
raise UnauthorizedError()
|
|
47
53
|
return user
|
|
48
54
|
|
|
55
|
+
|
|
49
56
|
async def validate_session(value: str) -> dict:
|
|
50
57
|
return await lookup_user_by_session(value)
|
|
51
58
|
|
|
59
|
+
|
|
52
60
|
bearer = HTTPBearerAuth(validate_token, prefix="user_")
|
|
53
61
|
session = APIKeyCookieAuth("session", validate_session, secret_key="...")
|
|
54
62
|
|
|
@@ -57,6 +65,7 @@ auth = MultiAuth(bearer, session)
|
|
|
57
65
|
|
|
58
66
|
app = FastAPI()
|
|
59
67
|
|
|
68
|
+
|
|
60
69
|
@app.get("/me")
|
|
61
70
|
async def me(user=Security(auth)):
|
|
62
71
|
return user
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastapi-multiauth"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
keywords = [
|
|
10
|
+
"fastapi",
|
|
11
|
+
"authentication",
|
|
12
|
+
"security",
|
|
13
|
+
"oauth",
|
|
14
|
+
"api-key",
|
|
15
|
+
"bearer",
|
|
16
|
+
"cookie",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Framework :: AsyncIO",
|
|
21
|
+
"Framework :: FastAPI",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Programming Language :: Python :: 3.14",
|
|
30
|
+
"Topic :: Internet :: WWW/HTTP :: Session",
|
|
31
|
+
"Topic :: Security",
|
|
32
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
33
|
+
"Topic :: Software Development :: Libraries",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
]
|
|
36
|
+
dependencies = [
|
|
37
|
+
"anyio>=3.7",
|
|
38
|
+
"async-lru>=2.0",
|
|
39
|
+
"fastapi>=0.100.0",
|
|
40
|
+
"httpx>=0.25.0",
|
|
41
|
+
"itsdangerous>=2.1",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[[project.authors]]
|
|
45
|
+
name = "d3vyce"
|
|
46
|
+
email = "contact@d3vyce.fr"
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://github.com/d3vyce/fastapi-multiauth"
|
|
50
|
+
Documentation = "https://fastapi-multiauth.d3vyce.fr/"
|
|
51
|
+
Repository = "https://github.com/d3vyce/fastapi-multiauth"
|
|
52
|
+
Issues = "https://github.com/d3vyce/fastapi-multiauth/issues"
|
|
53
|
+
|
|
54
|
+
[project.optional-dependencies]
|
|
55
|
+
jwt = ["pyjwt[crypto]>=2.13.0"]
|
|
56
|
+
oauth = ["httpx-oauth>=0.16"]
|
|
57
|
+
|
|
58
|
+
[dependency-groups]
|
|
59
|
+
dev = [
|
|
60
|
+
{ include-group = "tests" },
|
|
61
|
+
{ include-group = "docs" },
|
|
62
|
+
"fastapi_multiauth[jwt,oauth]",
|
|
63
|
+
"prek>=0.3.8",
|
|
64
|
+
"ruff>=0.1.0",
|
|
65
|
+
"ty>=0.0.34",
|
|
66
|
+
]
|
|
67
|
+
tests = [
|
|
68
|
+
"coverage>=7.0.0",
|
|
69
|
+
"pytest-anyio>=0.0.0",
|
|
70
|
+
"pytest-cov>=4.0.0",
|
|
71
|
+
"pytest>=8.0.0",
|
|
72
|
+
"respx>=0.22",
|
|
73
|
+
]
|
|
74
|
+
docs = [
|
|
75
|
+
"mkdocstrings-python>=2.0.2",
|
|
76
|
+
"zensical>=0.0.30",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[build-system]
|
|
80
|
+
requires = ["uv_build>=0.11.21,<0.13.0"]
|
|
81
|
+
build-backend = "uv_build"
|
|
82
|
+
|
|
83
|
+
[tool.ruff.lint]
|
|
84
|
+
extend-select = ["S"]
|
|
85
|
+
|
|
86
|
+
[tool.ruff.lint.per-file-ignores]
|
|
87
|
+
"tests/**" = [
|
|
88
|
+
"S",
|
|
89
|
+
"B008",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
[tool.pytest.ini_options]
|
|
93
|
+
testpaths = ["tests"]
|
|
94
|
+
anyio_mode = "auto"
|
|
95
|
+
filterwarnings = ["ignore::DeprecationWarning"]
|
|
96
|
+
|
|
97
|
+
[tool.coverage.run]
|
|
98
|
+
source = ["src/fastapi_multiauth"]
|
|
99
|
+
branch = true
|
|
100
|
+
|
|
101
|
+
[tool.coverage.report]
|
|
102
|
+
exclude_lines = [
|
|
103
|
+
"pragma: no cover",
|
|
104
|
+
"if TYPE_CHECKING:",
|
|
105
|
+
"raise NotImplementedError",
|
|
106
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.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"
|
|
@@ -44,7 +44,7 @@ Issues = "https://github.com/d3vyce/fastapi-multiauth/issues"
|
|
|
44
44
|
|
|
45
45
|
[project.optional-dependencies]
|
|
46
46
|
jwt = [
|
|
47
|
-
"pyjwt[crypto]>=2.
|
|
47
|
+
"pyjwt[crypto]>=2.13.0",
|
|
48
48
|
]
|
|
49
49
|
oauth = [
|
|
50
50
|
"httpx-oauth>=0.16",
|
|
@@ -72,7 +72,7 @@ docs = [
|
|
|
72
72
|
]
|
|
73
73
|
|
|
74
74
|
[build-system]
|
|
75
|
-
requires = ["uv_build>=0.11.
|
|
75
|
+
requires = ["uv_build>=0.11.21,<0.13.0"]
|
|
76
76
|
build-backend = "uv_build"
|
|
77
77
|
|
|
78
78
|
[tool.ruff.lint]
|
|
@@ -80,7 +80,7 @@ build-backend = "uv_build"
|
|
|
80
80
|
extend-select = ["S"]
|
|
81
81
|
|
|
82
82
|
[tool.ruff.lint.per-file-ignores]
|
|
83
|
-
"tests/**" = ["S"]
|
|
83
|
+
"tests/**" = ["S", "B008"]
|
|
84
84
|
|
|
85
85
|
[tool.pytest.ini_options]
|
|
86
86
|
testpaths = ["tests"]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Shared HTTP transport hygiene for the OAuth and JWT modules."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import json
|
|
4
5
|
from typing import Any
|
|
5
6
|
from urllib.parse import urlsplit
|
|
@@ -15,7 +16,7 @@ DEFAULT_TIMEOUT = 10.0
|
|
|
15
16
|
def _require_https(url: str, description: str) -> str:
|
|
16
17
|
"""Reject non-string URLs and URLs that would send credentials over plaintext HTTP."""
|
|
17
18
|
if not isinstance(url, str):
|
|
18
|
-
raise ValueError(f"{description} must be a string URL (got {url!r})")
|
|
19
|
+
raise ValueError(f"{description} must be a string URL (got {url!r})") # noqa: TRY004
|
|
19
20
|
parsed = urlsplit(url)
|
|
20
21
|
if parsed.scheme == "https":
|
|
21
22
|
return url
|
|
@@ -31,11 +32,16 @@ async def _get_json(
|
|
|
31
32
|
error_cls: type[Exception],
|
|
32
33
|
description: str,
|
|
33
34
|
headers: dict[str, str] | None = None,
|
|
35
|
+
client: httpx.AsyncClient | None = None,
|
|
34
36
|
) -> Any:
|
|
35
37
|
"""GET *url* and decode the JSON body, mapping failures to *error_cls*."""
|
|
36
|
-
async with
|
|
38
|
+
async with (
|
|
39
|
+
contextlib.nullcontext(client)
|
|
40
|
+
if client is not None
|
|
41
|
+
else httpx.AsyncClient(timeout=timeout)
|
|
42
|
+
) as http:
|
|
37
43
|
try:
|
|
38
|
-
resp = await
|
|
44
|
+
resp = await http.get(url, headers=headers)
|
|
39
45
|
resp.raise_for_status()
|
|
40
46
|
return resp.json()
|
|
41
47
|
except httpx.HTTPError as exc:
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import copy
|
|
4
4
|
import inspect
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
-
from
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
7
8
|
|
|
8
9
|
from fastapi import Depends, HTTPException, Request
|
|
9
10
|
from fastapi.security import SecurityScopes
|
|
@@ -12,7 +13,8 @@ from fastapi.security.base import SecurityBase
|
|
|
12
13
|
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
13
14
|
from fastapi_multiauth.utils import add_challenge, challenge_headers, ensure_async
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from typing_extensions import Self
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
def _reject_scopes_kwarg(kwargs: dict[str, Any]) -> None:
|
|
@@ -71,7 +73,7 @@ class _DocOnlyScheme(SecurityBase):
|
|
|
71
73
|
self.model = scheme.model
|
|
72
74
|
self.scheme_name = scheme.scheme_name
|
|
73
75
|
|
|
74
|
-
async def __call__(self, request: Request) -> None:
|
|
76
|
+
async def __call__(self, request: Request) -> None:
|
|
75
77
|
return None
|
|
76
78
|
|
|
77
79
|
|
|
@@ -212,7 +214,7 @@ class ValidatedAuthSource(AuthSource):
|
|
|
212
214
|
"""Validate a credential, forwarding route-declared scopes to the validator."""
|
|
213
215
|
return await self._call_validator(credential, scopes=scopes)
|
|
214
216
|
|
|
215
|
-
def require(self
|
|
217
|
+
def require(self, **kwargs: Any) -> "Self":
|
|
216
218
|
"""Return a copy of this source with additional (or overriding) validator kwargs."""
|
|
217
219
|
_reject_scopes_kwarg(kwargs)
|
|
218
220
|
clone = copy.copy(self)
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import time
|
|
5
|
-
from collections.abc import Sequence
|
|
6
|
-
from typing import Any
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from typing import Any
|
|
7
7
|
|
|
8
8
|
import anyio
|
|
9
9
|
from fastapi import HTTPException, status
|
|
@@ -149,7 +149,7 @@ class JWTValidator:
|
|
|
149
149
|
leeway=self._leeway,
|
|
150
150
|
options=self._decode_options,
|
|
151
151
|
)
|
|
152
|
-
except self._jwt.
|
|
152
|
+
except (self._jwt.exceptions.PyJWTError, TypeError, ValueError):
|
|
153
153
|
raise UnauthorizedError() from None
|
|
154
154
|
if scopes:
|
|
155
155
|
missing = set(scopes) - self._granted_scopes(claims)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""OAuth 2.0 / OIDC network operations: discovery, code exchange, userinfo."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import functools
|
|
4
5
|
from typing import Any, Literal, NamedTuple
|
|
5
6
|
|
|
@@ -74,6 +75,7 @@ def _oauth2_client_class() -> type:
|
|
|
74
75
|
access_token_endpoint: str,
|
|
75
76
|
timeout: float,
|
|
76
77
|
token_endpoint_auth_method: TokenEndpointAuthMethod,
|
|
78
|
+
client: httpx.AsyncClient | None,
|
|
77
79
|
) -> None:
|
|
78
80
|
super().__init__(
|
|
79
81
|
client_id,
|
|
@@ -85,8 +87,13 @@ def _oauth2_client_class() -> type:
|
|
|
85
87
|
token_endpoint_auth_method=token_endpoint_auth_method,
|
|
86
88
|
)
|
|
87
89
|
self._timeout = timeout
|
|
90
|
+
self._client = client
|
|
88
91
|
|
|
89
|
-
def get_httpx_client(self) ->
|
|
92
|
+
def get_httpx_client(self) -> Any:
|
|
93
|
+
# httpx-oauth only uses the return value as an async context
|
|
94
|
+
# manager, so a borrowed client is wrapped to survive the exit.
|
|
95
|
+
if self._client is not None:
|
|
96
|
+
return contextlib.nullcontext(self._client)
|
|
90
97
|
return httpx.AsyncClient(timeout=self._timeout)
|
|
91
98
|
|
|
92
99
|
return _TimeoutOAuth2
|
|
@@ -159,6 +166,7 @@ async def oauth_exchange_code(
|
|
|
159
166
|
code_verifier: str | None = None,
|
|
160
167
|
token_endpoint_auth_method: TokenEndpointAuthMethod = "client_secret_post", # noqa: S107 — method name, not a secret
|
|
161
168
|
timeout: float = DEFAULT_TIMEOUT,
|
|
169
|
+
client: httpx.AsyncClient | None = None,
|
|
162
170
|
) -> dict[str, Any]:
|
|
163
171
|
"""Exchange an authorization code for tokens and return the token response.
|
|
164
172
|
|
|
@@ -176,7 +184,11 @@ async def oauth_exchange_code(
|
|
|
176
184
|
(from :func:`~fastapi_multiauth.oauth.oauth_generate_pkce_pair`).
|
|
177
185
|
token_endpoint_auth_method: How credentials are sent — in the POST body
|
|
178
186
|
(``client_secret_post``, default) or as a Basic header.
|
|
179
|
-
timeout: Timeout in seconds for the token request.
|
|
187
|
+
timeout: Timeout in seconds for the token request. Ignored when
|
|
188
|
+
*client* is provided.
|
|
189
|
+
client: Optional ``httpx.AsyncClient`` to reuse (connection pooling
|
|
190
|
+
across the login flow). Borrowed, not closed; its own configuration
|
|
191
|
+
(timeouts included) governs the request.
|
|
180
192
|
|
|
181
193
|
Returns:
|
|
182
194
|
The full token response as a ``dict`` (``access_token`` plus whatever
|
|
@@ -190,7 +202,7 @@ async def oauth_exchange_code(
|
|
|
190
202
|
_require_https(token_url, "OAuth token_url")
|
|
191
203
|
|
|
192
204
|
oauth_client = _oauth2_client_class()(
|
|
193
|
-
client_id, client_secret, token_url, timeout, token_endpoint_auth_method
|
|
205
|
+
client_id, client_secret, token_url, timeout, token_endpoint_auth_method, client
|
|
194
206
|
)
|
|
195
207
|
try:
|
|
196
208
|
token_data = await oauth_client.get_access_token(
|
|
@@ -221,13 +233,18 @@ async def oauth_fetch_userinfo(
|
|
|
221
233
|
userinfo_url: str,
|
|
222
234
|
access_token: str,
|
|
223
235
|
timeout: float = DEFAULT_TIMEOUT,
|
|
236
|
+
client: httpx.AsyncClient | None = None,
|
|
224
237
|
) -> dict[str, Any]:
|
|
225
238
|
"""Fetch the userinfo payload with a bearer access token.
|
|
226
239
|
|
|
227
240
|
Args:
|
|
228
241
|
userinfo_url: Provider's userinfo endpoint.
|
|
229
242
|
access_token: Access token from :func:`oauth_exchange_code`.
|
|
230
|
-
timeout: Timeout in seconds for the userinfo request.
|
|
243
|
+
timeout: Timeout in seconds for the userinfo request. Ignored when
|
|
244
|
+
*client* is provided.
|
|
245
|
+
client: Optional ``httpx.AsyncClient`` to reuse (connection pooling
|
|
246
|
+
across the login flow). Borrowed, not closed; its own configuration
|
|
247
|
+
(timeouts included) governs the request.
|
|
231
248
|
|
|
232
249
|
Returns:
|
|
233
250
|
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
|
|
@@ -243,4 +260,5 @@ async def oauth_fetch_userinfo(
|
|
|
243
260
|
error_cls=OAuthUserinfoError,
|
|
244
261
|
description="userinfo",
|
|
245
262
|
headers={"Authorization": f"Bearer {access_token}"},
|
|
263
|
+
client=client,
|
|
246
264
|
)
|
|
@@ -98,9 +98,10 @@ def oauth_encode_state(url: str, state_token: str) -> str:
|
|
|
98
98
|
|
|
99
99
|
def _destination_allowed(destination: str, allowed_hosts: Sequence[str]) -> bool:
|
|
100
100
|
"""Check a decoded destination against the open-redirect allowlist."""
|
|
101
|
-
# Browsers normalize backslashes to slashes
|
|
102
|
-
# scheme-relative redirect
|
|
103
|
-
|
|
101
|
+
# Browsers normalize backslashes to slashes ("/\evil.com" becomes a
|
|
102
|
+
# scheme-relative redirect) and strip tabs/newlines before parsing
|
|
103
|
+
# ("/\t/evil.com" becomes "//evil.com") — reject control chars outright.
|
|
104
|
+
if "\\" in destination or any(ch <= " " for ch in destination):
|
|
104
105
|
return False
|
|
105
106
|
parsed = urlsplit(destination)
|
|
106
107
|
if not parsed.scheme and not parsed.netloc:
|
|
@@ -125,7 +126,8 @@ def oauth_decode_state(
|
|
|
125
126
|
Args:
|
|
126
127
|
state: Raw ``state`` query parameter from the callback.
|
|
127
128
|
expected_state_token: Token stored before the authorization redirect;
|
|
128
|
-
a mismatch returns
|
|
129
|
+
a mismatch — or an empty token (e.g. a session miss) — returns
|
|
130
|
+
``fallback``.
|
|
129
131
|
fallback: URL returned when ``state`` is absent, malformed, or fails
|
|
130
132
|
verification.
|
|
131
133
|
allowed_hosts: Open-redirect guard — the decoded destination must be a
|
|
@@ -135,6 +137,10 @@ def oauth_decode_state(
|
|
|
135
137
|
Returns:
|
|
136
138
|
The destination URL embedded in ``state``, or ``fallback``.
|
|
137
139
|
"""
|
|
140
|
+
# An empty expected token (e.g. session.get(..., "") on a session miss)
|
|
141
|
+
# must never match — compare_digest("", "") is True.
|
|
142
|
+
if not expected_state_token:
|
|
143
|
+
return fallback
|
|
138
144
|
if not state or state == "null": # "null" guards against JS JSON.stringify(null)
|
|
139
145
|
return fallback
|
|
140
146
|
try:
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Cookie-based authentication source."""
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
-
from collections.abc import Sequence
|
|
5
|
-
from typing import Any,
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from typing import Any, Literal
|
|
6
6
|
|
|
7
7
|
from fastapi import Request, Response
|
|
8
8
|
from fastapi.security import APIKeyCookie
|
|
@@ -4,8 +4,10 @@ import functools
|
|
|
4
4
|
import hashlib
|
|
5
5
|
import hmac
|
|
6
6
|
import inspect
|
|
7
|
-
from
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
10
|
+
import anyio.to_thread
|
|
9
11
|
from fastapi import HTTPException, Request
|
|
10
12
|
|
|
11
13
|
|
|
@@ -43,7 +45,7 @@ def ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
43
45
|
|
|
44
46
|
@functools.wraps(fn)
|
|
45
47
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
46
|
-
result = fn
|
|
48
|
+
result = await anyio.to_thread.run_sync(functools.partial(fn, *args, **kwargs))
|
|
47
49
|
if inspect.isawaitable(result):
|
|
48
50
|
return await result
|
|
49
51
|
return result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_multiauth-0.1.0 → fastapi_multiauth-0.3.0}/src/fastapi_multiauth/sources/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|