fastapi-multiauth 0.6.0__tar.gz → 0.7.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.6.0 → fastapi_multiauth-0.7.0}/PKG-INFO +7 -5
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/README.md +3 -1
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/pyproject.toml +9 -6
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/pyproject.toml.orig +6 -6
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/__init__.py +3 -2
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/abc.py +153 -25
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/exceptions.py +31 -0
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/basic.py +7 -3
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/bearer.py +7 -3
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/cookie.py +26 -5
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/header.py +2 -2
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/multi.py +45 -10
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/query.py +2 -2
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/surface.py +14 -2
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/utils.py +37 -2
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/LICENSE +0 -0
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/_http.py +0 -0
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/_imports.py +0 -0
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/jwt.py +0 -0
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/client.py +0 -0
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/utils.py +0 -0
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/py.typed +0 -0
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
- {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/oauth2.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-multiauth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -13,11 +13,11 @@ Classifier: Framework :: FastAPI
|
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: Operating System :: OS Independent
|
|
15
15
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
21
21
|
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
22
22
|
Classifier: Topic :: Security
|
|
23
23
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
@@ -25,12 +25,12 @@ 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.123.0
|
|
28
|
+
Requires-Dist: fastapi>=0.123.0,!=0.137.0,!=0.137.1
|
|
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'
|
|
32
32
|
Requires-Dist: httpx-oauth>=0.16 ; extra == 'oauth'
|
|
33
|
-
Requires-Python: >=3.
|
|
33
|
+
Requires-Python: >=3.11
|
|
34
34
|
Project-URL: Homepage, https://github.com/d3vyce/fastapi-multiauth
|
|
35
35
|
Project-URL: Documentation, https://fastapi-multiauth.d3vyce.fr/
|
|
36
36
|
Project-URL: Repository, https://github.com/d3vyce/fastapi-multiauth
|
|
@@ -49,7 +49,7 @@ Composable authentication sources for FastAPI: bring your own validator, combine
|
|
|
49
49
|
[](https://github.com/astral-sh/ty)
|
|
50
50
|
[](https://github.com/astral-sh/uv)
|
|
51
51
|
[](https://github.com/astral-sh/ruff)
|
|
52
|
-
[](https://www.python.org/downloads/)
|
|
53
53
|
[](https://opensource.org/licenses/MIT)
|
|
54
54
|
|
|
55
55
|
---
|
|
@@ -126,6 +126,8 @@ async def me(user=Security(auth)):
|
|
|
126
126
|
- **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
|
|
127
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.
|
|
128
128
|
- **Security scopes**: `Security(auth, scopes=[...])` forwards the declared scopes to validators that accept a `scopes` parameter.
|
|
129
|
+
- **WebSocket routes**: every source and `MultiAuth` guard `@app.websocket` routes too, refusing the handshake with the same 401/403.
|
|
130
|
+
- **Freshness / step-up auth**: `source.fresh(max_age=...)` refuses a credential proven too long ago on the endpoints that can take over an account, with the [RFC 9470](https://datatracker.ietf.org/doc/html/rfc9470) challenge that tells a client to re-authenticate. Signed cookies date themselves; anything else reads the instant off your identity.
|
|
129
131
|
- **Correct HTTP semantics**: 401 with `WWW-Authenticate` challenges ([RFC 7235](https://datatracker.ietf.org/doc/html/rfc7235)) and 403 via `ForbiddenError`.
|
|
130
132
|
- **OAuth 2.0 / OIDC helpers** (`fastapi-multiauth[oauth]` extra): async discovery with TTL caching, HTTPS enforcement, CSRF-protected `state` encoding, PKCE (S256), and code exchange delegated to [httpx-oauth](https://github.com/frankie567/httpx-oauth).
|
|
131
133
|
|
|
@@ -8,7 +8,7 @@ Composable authentication sources for FastAPI: bring your own validator, combine
|
|
|
8
8
|
[](https://github.com/astral-sh/ty)
|
|
9
9
|
[](https://github.com/astral-sh/uv)
|
|
10
10
|
[](https://github.com/astral-sh/ruff)
|
|
11
|
-
[](https://www.python.org/downloads/)
|
|
12
12
|
[](https://opensource.org/licenses/MIT)
|
|
13
13
|
|
|
14
14
|
---
|
|
@@ -85,6 +85,8 @@ async def me(user=Security(auth)):
|
|
|
85
85
|
- **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
|
|
86
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.
|
|
87
87
|
- **Security scopes**: `Security(auth, scopes=[...])` forwards the declared scopes to validators that accept a `scopes` parameter.
|
|
88
|
+
- **WebSocket routes**: every source and `MultiAuth` guard `@app.websocket` routes too, refusing the handshake with the same 401/403.
|
|
89
|
+
- **Freshness / step-up auth**: `source.fresh(max_age=...)` refuses a credential proven too long ago on the endpoints that can take over an account, with the [RFC 9470](https://datatracker.ietf.org/doc/html/rfc9470) challenge that tells a client to re-authenticate. Signed cookies date themselves; anything else reads the instant off your identity.
|
|
88
90
|
- **Correct HTTP semantics**: 401 with `WWW-Authenticate` challenges ([RFC 7235](https://datatracker.ietf.org/doc/html/rfc7235)) and 403 via `ForbiddenError`.
|
|
89
91
|
- **OAuth 2.0 / OIDC helpers** (`fastapi-multiauth[oauth]` extra): async discovery with TTL caching, HTTPS enforcement, CSRF-protected `state` encoding, PKCE (S256), and code exchange delegated to [httpx-oauth](https://github.com/frankie567/httpx-oauth).
|
|
90
92
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.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"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
8
|
-
requires-python = ">=3.
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
9
|
keywords = [
|
|
10
10
|
"fastapi",
|
|
11
11
|
"authentication",
|
|
@@ -22,11 +22,11 @@ classifiers = [
|
|
|
22
22
|
"Intended Audience :: Developers",
|
|
23
23
|
"Operating System :: OS Independent",
|
|
24
24
|
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
-
"Programming Language :: Python :: 3.10",
|
|
26
25
|
"Programming Language :: Python :: 3.11",
|
|
27
26
|
"Programming Language :: Python :: 3.12",
|
|
28
27
|
"Programming Language :: Python :: 3.13",
|
|
29
28
|
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Programming Language :: Python :: 3.15",
|
|
30
30
|
"Topic :: Internet :: WWW/HTTP :: Session",
|
|
31
31
|
"Topic :: Security",
|
|
32
32
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
@@ -36,7 +36,7 @@ classifiers = [
|
|
|
36
36
|
dependencies = [
|
|
37
37
|
"anyio>=3.7",
|
|
38
38
|
"async-lru>=2.0",
|
|
39
|
-
"fastapi>=0.123.0",
|
|
39
|
+
"fastapi>=0.123.0,!=0.137.0,!=0.137.1",
|
|
40
40
|
"httpx>=0.25.0",
|
|
41
41
|
"itsdangerous>=2.1",
|
|
42
42
|
]
|
|
@@ -78,11 +78,14 @@ docs = [
|
|
|
78
78
|
]
|
|
79
79
|
|
|
80
80
|
[build-system]
|
|
81
|
-
requires = ["uv_build>=0.11.21,<0.
|
|
81
|
+
requires = ["uv_build>=0.11.21,<0.14.0"]
|
|
82
82
|
build-backend = "uv_build"
|
|
83
83
|
|
|
84
84
|
[tool.ruff.lint]
|
|
85
|
-
extend-select = [
|
|
85
|
+
extend-select = [
|
|
86
|
+
"S",
|
|
87
|
+
"UP",
|
|
88
|
+
]
|
|
86
89
|
|
|
87
90
|
[tool.ruff.lint.per-file-ignores]
|
|
88
91
|
"tests/**" = [
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.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"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
8
|
-
requires-python = ">=3.
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "d3vyce", email = "contact@d3vyce.fr" }
|
|
11
11
|
]
|
|
@@ -17,11 +17,11 @@ classifiers = [
|
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
18
|
"Operating System :: OS Independent",
|
|
19
19
|
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
-
"Programming Language :: Python :: 3.10",
|
|
21
20
|
"Programming Language :: Python :: 3.11",
|
|
22
21
|
"Programming Language :: Python :: 3.12",
|
|
23
22
|
"Programming Language :: Python :: 3.13",
|
|
24
23
|
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Programming Language :: Python :: 3.15",
|
|
25
25
|
"Topic :: Internet :: WWW/HTTP :: Session",
|
|
26
26
|
"Topic :: Security",
|
|
27
27
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
@@ -31,7 +31,7 @@ classifiers = [
|
|
|
31
31
|
dependencies = [
|
|
32
32
|
"anyio>=3.7",
|
|
33
33
|
"async-lru>=2.0",
|
|
34
|
-
"fastapi>=0.123.0",
|
|
34
|
+
"fastapi>=0.123.0,!=0.137.0,!=0.137.1",
|
|
35
35
|
"httpx>=0.25.0",
|
|
36
36
|
"itsdangerous>=2.1",
|
|
37
37
|
]
|
|
@@ -73,12 +73,12 @@ docs = [
|
|
|
73
73
|
]
|
|
74
74
|
|
|
75
75
|
[build-system]
|
|
76
|
-
requires = ["uv_build>=0.11.21,<0.
|
|
76
|
+
requires = ["uv_build>=0.11.21,<0.14.0"]
|
|
77
77
|
build-backend = "uv_build"
|
|
78
78
|
|
|
79
79
|
[tool.ruff.lint]
|
|
80
80
|
# "S" = flake8-bandit security rules, scoped to the library source.
|
|
81
|
-
extend-select = ["S"]
|
|
81
|
+
extend-select = ["S", "UP"]
|
|
82
82
|
|
|
83
83
|
[tool.ruff.lint.per-file-ignores]
|
|
84
84
|
"tests/**" = ["S", "B008"]
|
|
@@ -11,7 +11,7 @@ exported here, and require the ``oauth`` extra. Import them explicitly::
|
|
|
11
11
|
|
|
12
12
|
from . import oauth
|
|
13
13
|
from .abc import AuthSource
|
|
14
|
-
from .exceptions import ForbiddenError, UnauthorizedError
|
|
14
|
+
from .exceptions import ForbiddenError, StaleCredentialError, UnauthorizedError
|
|
15
15
|
from .jwt import JWTValidator
|
|
16
16
|
from .sources import (
|
|
17
17
|
APIKeyCookieAuth,
|
|
@@ -27,7 +27,7 @@ from .sources import (
|
|
|
27
27
|
from .surface import RouteAuth, SchemeRequirement, auth_surface
|
|
28
28
|
from .utils import hash_token, verify_token_hash
|
|
29
29
|
|
|
30
|
-
__version__ = "0.
|
|
30
|
+
__version__ = "0.7.0"
|
|
31
31
|
|
|
32
32
|
__all__ = [
|
|
33
33
|
"APIKeyCookieAuth",
|
|
@@ -44,6 +44,7 @@ __all__ = [
|
|
|
44
44
|
"OpenIdConnectAuth",
|
|
45
45
|
"RouteAuth",
|
|
46
46
|
"SchemeRequirement",
|
|
47
|
+
"StaleCredentialError",
|
|
47
48
|
"UnauthorizedError",
|
|
48
49
|
"auth_surface",
|
|
49
50
|
"hash_token",
|
|
@@ -2,20 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
4
|
import inspect
|
|
5
|
+
import math
|
|
5
6
|
from abc import ABC, abstractmethod
|
|
6
|
-
from collections.abc import Callable, Mapping
|
|
7
|
-
from
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Self
|
|
8
10
|
|
|
9
|
-
from fastapi import HTTPException
|
|
11
|
+
from fastapi import HTTPException
|
|
12
|
+
from fastapi.requests import HTTPConnection
|
|
10
13
|
from fastapi.security import SecurityScopes
|
|
11
14
|
from fastapi.security.base import SecurityBase
|
|
12
15
|
|
|
13
|
-
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
14
|
-
from fastapi_multiauth.utils import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
from fastapi_multiauth.exceptions import StaleCredentialError, UnauthorizedError
|
|
17
|
+
from fastapi_multiauth.utils import (
|
|
18
|
+
add_challenge,
|
|
19
|
+
challenge_headers,
|
|
20
|
+
credential_age,
|
|
21
|
+
ensure_async,
|
|
22
|
+
step_up_challenge,
|
|
23
|
+
)
|
|
19
24
|
|
|
20
25
|
_RESERVED_KWARGS = {
|
|
21
26
|
"scopes": "injected from the route by Security(..., scopes=[...])",
|
|
@@ -101,6 +106,14 @@ def _anonymous_scopes_error(owner: object, scopes: list[str]) -> RuntimeError:
|
|
|
101
106
|
)
|
|
102
107
|
|
|
103
108
|
|
|
109
|
+
def _fresh_optional_error(owner: object) -> ValueError:
|
|
110
|
+
"""Error for a dependency asking for both anonymous callers and freshness."""
|
|
111
|
+
return ValueError(
|
|
112
|
+
f"{type(owner).__name__} cannot be both optional() and fresh(): an "
|
|
113
|
+
"anonymous caller can never be fresh"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
104
117
|
def _undeclared_scopes_error(
|
|
105
118
|
owner: object, unknown: list[str], catalogue: dict[str, str]
|
|
106
119
|
) -> RuntimeError:
|
|
@@ -124,7 +137,7 @@ class _DocOnlyScheme(SecurityBase):
|
|
|
124
137
|
self.model = scheme.model
|
|
125
138
|
self.scheme_name = scheme.scheme_name
|
|
126
139
|
|
|
127
|
-
async def __call__(self, request:
|
|
140
|
+
async def __call__(self, request: HTTPConnection) -> None:
|
|
128
141
|
return None
|
|
129
142
|
|
|
130
143
|
|
|
@@ -146,18 +159,30 @@ def _carry_scheme(dependency: Any, scheme: SecurityBase) -> None:
|
|
|
146
159
|
dependency.scheme_name = scheme.scheme_name
|
|
147
160
|
|
|
148
161
|
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class _Freshness:
|
|
164
|
+
"""The window set by :meth:`AuthSource.fresh`."""
|
|
165
|
+
|
|
166
|
+
max_age: float
|
|
167
|
+
leeway: float
|
|
168
|
+
read: Callable[[Any], Awaitable[Any]] | None
|
|
169
|
+
"""Caller-supplied reader of the instant, ``None`` to use the source's own."""
|
|
170
|
+
|
|
171
|
+
|
|
149
172
|
class AuthSource(ABC):
|
|
150
173
|
"""Abstract base class for authentication sources.
|
|
151
174
|
|
|
152
175
|
Subclasses implement :meth:`extract` and :meth:`authenticate`; both
|
|
153
176
|
``Security(source)`` and ``MultiAuth`` route through that pair via
|
|
154
|
-
:meth:`dispatch`.
|
|
177
|
+
:meth:`dispatch`. Optional hooks: :meth:`www_authenticate` for the 401
|
|
178
|
+
challenge, :meth:`authenticated_at` for :meth:`fresh`.
|
|
155
179
|
"""
|
|
156
180
|
|
|
157
181
|
scheme: SecurityBase | None
|
|
158
182
|
_optional: bool = False
|
|
159
183
|
_scope_catalogue: dict[str, str] | None = None
|
|
160
184
|
"""The scopes this source publishes to OpenAPI, ``None`` when it has none."""
|
|
185
|
+
_freshness: _Freshness | None = None
|
|
161
186
|
|
|
162
187
|
def __init__(self, scheme: Any = None) -> None:
|
|
163
188
|
"""Set up the FastAPI dependency signature.
|
|
@@ -177,7 +202,7 @@ class AuthSource(ABC):
|
|
|
177
202
|
inspect.Parameter(
|
|
178
203
|
"request",
|
|
179
204
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
180
|
-
annotation=
|
|
205
|
+
annotation=HTTPConnection,
|
|
181
206
|
),
|
|
182
207
|
inspect.Parameter(
|
|
183
208
|
"security_scopes",
|
|
@@ -188,7 +213,7 @@ class AuthSource(ABC):
|
|
|
188
213
|
self.__signature__ = inspect.Signature(parameters, return_annotation=Any)
|
|
189
214
|
|
|
190
215
|
@abstractmethod
|
|
191
|
-
async def extract(self, request:
|
|
216
|
+
async def extract(self, request: HTTPConnection) -> str | None:
|
|
192
217
|
"""Extract the raw credential from the request without validating.
|
|
193
218
|
|
|
194
219
|
Must return ``None`` — never an empty string — when the credential is
|
|
@@ -207,7 +232,11 @@ class AuthSource(ABC):
|
|
|
207
232
|
return None
|
|
208
233
|
|
|
209
234
|
async def authenticate_scoped(
|
|
210
|
-
self,
|
|
235
|
+
self,
|
|
236
|
+
credential: str,
|
|
237
|
+
scopes: list[str],
|
|
238
|
+
*,
|
|
239
|
+
request: HTTPConnection | None = None,
|
|
211
240
|
) -> Any:
|
|
212
241
|
"""Validate a credential, enforcing the scopes declared on the route.
|
|
213
242
|
|
|
@@ -226,7 +255,7 @@ class AuthSource(ABC):
|
|
|
226
255
|
return type(self).authenticate_scoped is not AuthSource.authenticate_scoped
|
|
227
256
|
|
|
228
257
|
async def _call_scoped(
|
|
229
|
-
self, credential: str, scopes: list[str], *, request:
|
|
258
|
+
self, credential: str, scopes: list[str], *, request: HTTPConnection | None
|
|
230
259
|
) -> Any:
|
|
231
260
|
"""Call :meth:`authenticate_scoped`, honouring overrides predating ``request``."""
|
|
232
261
|
if self._scoped_takes_request:
|
|
@@ -234,21 +263,116 @@ class AuthSource(ABC):
|
|
|
234
263
|
return await self.authenticate_scoped(credential, scopes)
|
|
235
264
|
|
|
236
265
|
async def _authenticate_with_challenge(
|
|
237
|
-
self,
|
|
266
|
+
self,
|
|
267
|
+
credential: str,
|
|
268
|
+
scopes: list[str],
|
|
269
|
+
*,
|
|
270
|
+
request: HTTPConnection | None = None,
|
|
238
271
|
) -> Any:
|
|
239
|
-
"""Authenticate, attaching this source's challenge to any 401 raised.
|
|
272
|
+
"""Authenticate, attaching this source's challenge to any 401 raised.
|
|
273
|
+
|
|
274
|
+
Freshness is checked after the credential: an invalid one is refused as
|
|
275
|
+
invalid, not as stale.
|
|
276
|
+
"""
|
|
240
277
|
try:
|
|
241
|
-
|
|
278
|
+
identity = await self._call_scoped(credential, scopes, request=request)
|
|
242
279
|
except HTTPException as exc:
|
|
243
280
|
add_challenge(exc, self.www_authenticate())
|
|
244
281
|
raise
|
|
282
|
+
await self._enforce_freshness(credential, identity)
|
|
283
|
+
return identity
|
|
245
284
|
|
|
246
|
-
def optional(self) ->
|
|
247
|
-
"""Return a copy that yields ``None`` instead of 401 when no credential is sent.
|
|
285
|
+
def optional(self) -> Self:
|
|
286
|
+
"""Return a copy that yields ``None`` instead of 401 when no credential is sent.
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
ValueError: On a :meth:`fresh` copy: anonymous is never fresh.
|
|
290
|
+
"""
|
|
291
|
+
if self._freshness is not None:
|
|
292
|
+
raise _fresh_optional_error(self)
|
|
248
293
|
clone = copy.copy(self)
|
|
249
294
|
clone._optional = True
|
|
250
295
|
return clone
|
|
251
296
|
|
|
297
|
+
def authenticated_at(self, credential: str) -> Any:
|
|
298
|
+
"""When the factor behind *credential* was proven, for :meth:`fresh`.
|
|
299
|
+
|
|
300
|
+
Epoch seconds, a ``datetime``, or ``None`` when this source cannot date
|
|
301
|
+
the credential. Sources that can override this (a signed cookie carries
|
|
302
|
+
its mint time); the others pass ``authenticated_at=`` to :meth:`fresh`.
|
|
303
|
+
"""
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
def _dates_credentials(self) -> bool:
|
|
307
|
+
"""Whether this source can date a credential without a caller's help."""
|
|
308
|
+
return type(self).authenticated_at is not AuthSource.authenticated_at
|
|
309
|
+
|
|
310
|
+
def fresh(
|
|
311
|
+
self,
|
|
312
|
+
max_age: float,
|
|
313
|
+
*,
|
|
314
|
+
authenticated_at: Callable[..., Any] | None = None,
|
|
315
|
+
leeway: float = 0.0,
|
|
316
|
+
) -> Self:
|
|
317
|
+
"""Return a copy that refuses a credential proven more than *max_age* ago.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
max_age: Freshness window in seconds.
|
|
321
|
+
authenticated_at: Sync or async callable taking the identity and
|
|
322
|
+
returning the instant the caller proved a factor, as epoch
|
|
323
|
+
seconds or a ``datetime`` (naive read as UTC). Required when the
|
|
324
|
+
source cannot date its own credentials, and overrides
|
|
325
|
+
:meth:`authenticated_at`. ``None`` refuses the request.
|
|
326
|
+
leeway: Clock-skew tolerance in seconds, either way: an instant
|
|
327
|
+
further than *leeway* in the future is refused too.
|
|
328
|
+
|
|
329
|
+
Raises:
|
|
330
|
+
ValueError: Non-positive or non-finite *max_age*, negative or
|
|
331
|
+
non-finite *leeway*, an :meth:`optional` copy, a copy that
|
|
332
|
+
already has a window, or no way to date the credential.
|
|
333
|
+
"""
|
|
334
|
+
if not math.isfinite(max_age) or max_age <= 0:
|
|
335
|
+
raise ValueError("max_age must be a positive, finite number of seconds")
|
|
336
|
+
if not math.isfinite(leeway) or leeway < 0:
|
|
337
|
+
raise ValueError("leeway must be a finite, non-negative number of seconds")
|
|
338
|
+
if self._optional:
|
|
339
|
+
raise _fresh_optional_error(self)
|
|
340
|
+
if self._freshness is not None:
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"{type(self).__name__} already has a freshness window: call "
|
|
343
|
+
"fresh() once, on the source or on the MultiAuth"
|
|
344
|
+
)
|
|
345
|
+
if authenticated_at is None and not self._dates_credentials():
|
|
346
|
+
raise ValueError(
|
|
347
|
+
f"{type(self).__name__} cannot date a credential: "
|
|
348
|
+
"fresh() needs authenticated_at=..."
|
|
349
|
+
)
|
|
350
|
+
clone = copy.copy(self)
|
|
351
|
+
clone._freshness = _Freshness(
|
|
352
|
+
max_age=max_age,
|
|
353
|
+
leeway=leeway,
|
|
354
|
+
read=ensure_async(authenticated_at) if authenticated_at else None,
|
|
355
|
+
)
|
|
356
|
+
return clone
|
|
357
|
+
|
|
358
|
+
async def _enforce_freshness(self, credential: str, identity: Any) -> None:
|
|
359
|
+
"""Refuse an identity whose credential was proven outside the window."""
|
|
360
|
+
freshness = self._freshness
|
|
361
|
+
if freshness is None:
|
|
362
|
+
return
|
|
363
|
+
if freshness.read is not None:
|
|
364
|
+
instant = await freshness.read(identity)
|
|
365
|
+
else:
|
|
366
|
+
instant = self.authenticated_at(credential)
|
|
367
|
+
age = credential_age(instant)
|
|
368
|
+
if age is None or not (
|
|
369
|
+
-freshness.leeway <= age <= freshness.max_age + freshness.leeway
|
|
370
|
+
):
|
|
371
|
+
raise StaleCredentialError(
|
|
372
|
+
headers=step_up_challenge(self.www_authenticate(), freshness.max_age),
|
|
373
|
+
max_age=freshness.max_age,
|
|
374
|
+
)
|
|
375
|
+
|
|
252
376
|
def _reject_undeclared_scopes(self, scopes: list[str]) -> None:
|
|
253
377
|
"""Refuse a route declaring scopes this source never published."""
|
|
254
378
|
catalogue = self._scope_catalogue
|
|
@@ -258,7 +382,7 @@ class AuthSource(ABC):
|
|
|
258
382
|
if unknown:
|
|
259
383
|
raise _undeclared_scopes_error(self, unknown, catalogue)
|
|
260
384
|
|
|
261
|
-
async def dispatch(self, request:
|
|
385
|
+
async def dispatch(self, request: HTTPConnection, scopes: list[str]) -> Any:
|
|
262
386
|
"""Extract the credential, then authenticate it with the route scopes.
|
|
263
387
|
|
|
264
388
|
Raises:
|
|
@@ -319,7 +443,7 @@ class ValidatedAuthSource(AuthSource):
|
|
|
319
443
|
self,
|
|
320
444
|
*args: Any,
|
|
321
445
|
scopes: list[str],
|
|
322
|
-
request:
|
|
446
|
+
request: HTTPConnection | None = None,
|
|
323
447
|
**injected: Any,
|
|
324
448
|
) -> Any:
|
|
325
449
|
"""Invoke the validator with scope, request and configured kwargs forwarding."""
|
|
@@ -332,13 +456,17 @@ class ValidatedAuthSource(AuthSource):
|
|
|
332
456
|
return await self._validator(*args, **self._kwargs, **injected)
|
|
333
457
|
|
|
334
458
|
async def authenticate(
|
|
335
|
-
self, credential: str, *, request:
|
|
459
|
+
self, credential: str, *, request: HTTPConnection | None = None
|
|
336
460
|
) -> Any:
|
|
337
461
|
"""Validate a credential and return the identity (no route scopes)."""
|
|
338
462
|
return await self._call_scoped(credential, [], request=request)
|
|
339
463
|
|
|
340
464
|
async def authenticate_scoped(
|
|
341
|
-
self,
|
|
465
|
+
self,
|
|
466
|
+
credential: str,
|
|
467
|
+
scopes: list[str],
|
|
468
|
+
*,
|
|
469
|
+
request: HTTPConnection | None = None,
|
|
342
470
|
) -> Any:
|
|
343
471
|
"""Validate a credential, forwarding route scopes and request to the validator."""
|
|
344
472
|
return await self._call_validator(credential, scopes=scopes, request=request)
|
|
@@ -347,7 +475,7 @@ class ValidatedAuthSource(AuthSource):
|
|
|
347
475
|
"""The validator is the authority: every source funnels into it."""
|
|
348
476
|
return self._accepts_scopes
|
|
349
477
|
|
|
350
|
-
def require(self, **kwargs: Any) ->
|
|
478
|
+
def require(self, **kwargs: Any) -> Self:
|
|
351
479
|
"""Return a copy of this source with additional (or overriding) validator kwargs.
|
|
352
480
|
|
|
353
481
|
Reserved names (``scopes``, ``session_id``, ``request``) are rejected here too.
|
|
@@ -25,6 +25,37 @@ class UnauthorizedError(HTTPException):
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
class StaleCredentialError(UnauthorizedError):
|
|
29
|
+
"""HTTP 401 — the credential is valid but was proven too long ago.
|
|
30
|
+
|
|
31
|
+
Raised by a route guarded with :meth:`~fastapi_multiauth.AuthSource.fresh`.
|
|
32
|
+
``detail`` is the stable string ``"Insufficient user authentication"``, and
|
|
33
|
+
sources with an HTTP auth scheme carry the
|
|
34
|
+
`RFC 9470 <https://datatracker.ietf.org/doc/html/rfc9470>`_ step-up
|
|
35
|
+
challenge. A handler rendering its own body reads :attr:`max_age`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
max_age: float | None
|
|
39
|
+
"""The freshness window the credential missed, in seconds."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
detail: str = "Insufficient user authentication",
|
|
44
|
+
headers: dict[str, str] | None = None,
|
|
45
|
+
*,
|
|
46
|
+
max_age: float | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Initialize the exception.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
detail: Human-readable message returned in the response body.
|
|
52
|
+
headers: Extra response headers (e.g. the step-up challenge).
|
|
53
|
+
max_age: The freshness window that was missed, in seconds.
|
|
54
|
+
"""
|
|
55
|
+
self.max_age = max_age
|
|
56
|
+
super().__init__(detail=detail, headers=headers)
|
|
57
|
+
|
|
58
|
+
|
|
28
59
|
class ForbiddenError(HTTPException):
|
|
29
60
|
"""HTTP 403 — the identity is authenticated but lacks permission.
|
|
30
61
|
|
|
@@ -4,7 +4,7 @@ import base64
|
|
|
4
4
|
from collections.abc import Callable
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from fastapi import
|
|
7
|
+
from fastapi.requests import HTTPConnection
|
|
8
8
|
from fastapi.security import HTTPBasic
|
|
9
9
|
|
|
10
10
|
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
@@ -67,7 +67,7 @@ class HTTPBasicAuth(ValidatedAuthSource):
|
|
|
67
67
|
"""``Basic`` challenge, with the realm when configured (RFC 7617 §2)."""
|
|
68
68
|
return self._challenge
|
|
69
69
|
|
|
70
|
-
async def extract(self, request:
|
|
70
|
+
async def extract(self, request: HTTPConnection) -> str | None:
|
|
71
71
|
"""Return the base64 credentials blob, or ``None`` when absent or empty.
|
|
72
72
|
|
|
73
73
|
The ``Basic`` scheme is matched case-insensitively; decoding and the
|
|
@@ -76,7 +76,11 @@ class HTTPBasicAuth(ValidatedAuthSource):
|
|
|
76
76
|
return authorization_credential(request, "basic")
|
|
77
77
|
|
|
78
78
|
async def authenticate_scoped(
|
|
79
|
-
self,
|
|
79
|
+
self,
|
|
80
|
+
credential: str,
|
|
81
|
+
scopes: list[str],
|
|
82
|
+
*,
|
|
83
|
+
request: HTTPConnection | None = None,
|
|
80
84
|
) -> Any:
|
|
81
85
|
"""Decode the blob, forwarding route-declared scopes to the validator."""
|
|
82
86
|
try:
|
|
@@ -4,7 +4,7 @@ import secrets
|
|
|
4
4
|
from collections.abc import Callable
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from fastapi import
|
|
7
|
+
from fastapi.requests import HTTPConnection
|
|
8
8
|
from fastapi.security import HTTPBearer
|
|
9
9
|
|
|
10
10
|
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
@@ -22,7 +22,7 @@ class _BearerSource(ValidatedAuthSource):
|
|
|
22
22
|
"""``Bearer`` challenge per RFC 6750 §3."""
|
|
23
23
|
return "Bearer"
|
|
24
24
|
|
|
25
|
-
async def extract(self, request:
|
|
25
|
+
async def extract(self, request: HTTPConnection) -> str | None:
|
|
26
26
|
"""Return the bearer token, or ``None`` when absent, empty, or mismatched.
|
|
27
27
|
|
|
28
28
|
The ``Bearer`` scheme is matched case-insensitively; the prefix, when
|
|
@@ -36,7 +36,11 @@ class _BearerSource(ValidatedAuthSource):
|
|
|
36
36
|
return token
|
|
37
37
|
|
|
38
38
|
async def authenticate_scoped(
|
|
39
|
-
self,
|
|
39
|
+
self,
|
|
40
|
+
credential: str,
|
|
41
|
+
scopes: list[str],
|
|
42
|
+
*,
|
|
43
|
+
request: HTTPConnection | None = None,
|
|
40
44
|
) -> Any:
|
|
41
45
|
"""Validate the credential, re-checking the prefix and forwarding scopes."""
|
|
42
46
|
if self._prefix is not None and not credential.startswith(self._prefix):
|
|
@@ -5,7 +5,8 @@ import secrets
|
|
|
5
5
|
from collections.abc import Callable, Sequence
|
|
6
6
|
from typing import Any, Literal
|
|
7
7
|
|
|
8
|
-
from fastapi import
|
|
8
|
+
from fastapi import Response
|
|
9
|
+
from fastapi.requests import HTTPConnection
|
|
9
10
|
from fastapi.security import APIKeyCookie
|
|
10
11
|
from itsdangerous import BadSignature, TimestampSigner
|
|
11
12
|
|
|
@@ -149,6 +150,22 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
149
150
|
except BadSignature:
|
|
150
151
|
raise UnauthorizedError() from None
|
|
151
152
|
|
|
153
|
+
def authenticated_at(self, credential: str) -> float | None:
|
|
154
|
+
"""When the signed cookie was minted, ``None`` when it does not verify."""
|
|
155
|
+
if self._signer is None:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
_, signed_at = self._signer.unsign(
|
|
159
|
+
credential.encode(), max_age=self._ttl, return_timestamp=True
|
|
160
|
+
)
|
|
161
|
+
except BadSignature:
|
|
162
|
+
return None
|
|
163
|
+
return signed_at.timestamp()
|
|
164
|
+
|
|
165
|
+
def _dates_credentials(self) -> bool:
|
|
166
|
+
"""Only a signed cookie carries the timestamp :meth:`fresh` needs."""
|
|
167
|
+
return self._signer is not None
|
|
168
|
+
|
|
152
169
|
@property
|
|
153
170
|
def name(self) -> str:
|
|
154
171
|
"""Cookie name. Read-only: the signing salt is derived from it."""
|
|
@@ -159,11 +176,11 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
159
176
|
"""Cookie lifetime in seconds: use it to derive your session's expiry."""
|
|
160
177
|
return self._ttl
|
|
161
178
|
|
|
162
|
-
async def extract(self, request:
|
|
179
|
+
async def extract(self, request: HTTPConnection) -> str | None:
|
|
163
180
|
"""Return the raw cookie value, or ``None`` when absent or empty."""
|
|
164
181
|
return request.cookies.get(self._name) or None
|
|
165
182
|
|
|
166
|
-
def session_id_of(self, request:
|
|
183
|
+
def session_id_of(self, request: HTTPConnection) -> str | None:
|
|
167
184
|
"""Return *request*'s session id without running the validator.
|
|
168
185
|
|
|
169
186
|
``None`` when ``session_id`` is off, or the cookie is absent, expired
|
|
@@ -180,7 +197,11 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
180
197
|
return None
|
|
181
198
|
|
|
182
199
|
async def authenticate_scoped(
|
|
183
|
-
self,
|
|
200
|
+
self,
|
|
201
|
+
credential: str,
|
|
202
|
+
scopes: list[str],
|
|
203
|
+
*,
|
|
204
|
+
request: HTTPConnection | None = None,
|
|
184
205
|
) -> Any:
|
|
185
206
|
"""Verify the cookie, forwarding route-declared scopes to the validator."""
|
|
186
207
|
plain = self._verify(credential)
|
|
@@ -214,7 +235,7 @@ class APIKeyCookieAuth(ValidatedAuthSource):
|
|
|
214
235
|
return sid
|
|
215
236
|
|
|
216
237
|
def delete_cookie(
|
|
217
|
-
self, response: Response, request:
|
|
238
|
+
self, response: Response, request: HTTPConnection | None = None
|
|
218
239
|
) -> str | None:
|
|
219
240
|
"""Clear the session cookie (logout) from this client only.
|
|
220
241
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from collections.abc import Callable
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
-
from fastapi import
|
|
6
|
+
from fastapi.requests import HTTPConnection
|
|
7
7
|
from fastapi.security import APIKeyHeader
|
|
8
8
|
|
|
9
9
|
from ..abc import ValidatedAuthSource
|
|
@@ -44,6 +44,6 @@ class APIKeyHeaderAuth(ValidatedAuthSource):
|
|
|
44
44
|
**kwargs,
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
-
async def extract(self, request:
|
|
47
|
+
async def extract(self, request: HTTPConnection) -> str | None:
|
|
48
48
|
"""Return the API key from the configured header, or ``None`` when absent or empty."""
|
|
49
49
|
return request.headers.get(self._name) or None
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
4
|
import inspect
|
|
5
|
-
from
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from typing import Annotated, Any, Self, cast
|
|
6
7
|
|
|
7
|
-
from fastapi import Depends
|
|
8
|
+
from fastapi import Depends
|
|
9
|
+
from fastapi.requests import HTTPConnection
|
|
8
10
|
from fastapi.security import SecurityScopes
|
|
9
11
|
|
|
10
12
|
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
@@ -13,6 +15,7 @@ from ..abc import (
|
|
|
13
15
|
AuthSource,
|
|
14
16
|
_anonymous_scopes_error,
|
|
15
17
|
_carry_scheme,
|
|
18
|
+
_fresh_optional_error,
|
|
16
19
|
_unenforceable_scopes_error,
|
|
17
20
|
)
|
|
18
21
|
from ..utils import challenge_headers
|
|
@@ -51,7 +54,7 @@ class MultiAuth:
|
|
|
51
54
|
inspect.Parameter(
|
|
52
55
|
"request",
|
|
53
56
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
54
|
-
annotation=
|
|
57
|
+
annotation=HTTPConnection,
|
|
55
58
|
),
|
|
56
59
|
inspect.Parameter(
|
|
57
60
|
"security_scopes",
|
|
@@ -95,13 +98,48 @@ class MultiAuth:
|
|
|
95
98
|
"""Combined challenge of all sources (RFC 9110 §11.6.1), or ``None``."""
|
|
96
99
|
return self._www_authenticate
|
|
97
100
|
|
|
98
|
-
def optional(self) ->
|
|
99
|
-
"""Return a copy that yields ``None`` when no source has a credential.
|
|
101
|
+
def optional(self) -> Self:
|
|
102
|
+
"""Return a copy that yields ``None`` when no source has a credential.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: On a :meth:`fresh` copy: anonymous is never fresh.
|
|
106
|
+
"""
|
|
107
|
+
if any(source._freshness for source in self._sources):
|
|
108
|
+
raise _fresh_optional_error(self)
|
|
100
109
|
clone = copy.copy(self)
|
|
101
110
|
clone._optional = True
|
|
102
111
|
return clone
|
|
103
112
|
|
|
104
|
-
|
|
113
|
+
def fresh(
|
|
114
|
+
self,
|
|
115
|
+
max_age: float,
|
|
116
|
+
*,
|
|
117
|
+
authenticated_at: Callable[..., Any] | None = None,
|
|
118
|
+
leeway: float = 0.0,
|
|
119
|
+
) -> "MultiAuth":
|
|
120
|
+
"""Return a copy whose sources all require a recently proven credential.
|
|
121
|
+
|
|
122
|
+
Applies :meth:`AuthSource.fresh` to every source, so one that cannot be
|
|
123
|
+
dated refuses here instead of becoming the way around the window.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: As :meth:`AuthSource.fresh`, naming the source that
|
|
127
|
+
cannot be dated or already has a window of its own.
|
|
128
|
+
"""
|
|
129
|
+
if self._optional:
|
|
130
|
+
raise _fresh_optional_error(self)
|
|
131
|
+
return self._with_sources(
|
|
132
|
+
source.fresh(max_age, authenticated_at=authenticated_at, leeway=leeway)
|
|
133
|
+
for source in self._sources
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _with_sources(self, sources: Iterable[AuthSource]) -> "MultiAuth":
|
|
137
|
+
"""A new :class:`MultiAuth` over *sources*, carrying this one's flags."""
|
|
138
|
+
clone = MultiAuth(*sources)
|
|
139
|
+
clone._optional = self._optional
|
|
140
|
+
return clone
|
|
141
|
+
|
|
142
|
+
async def dispatch(self, request: HTTPConnection, scopes: list[str]) -> Any:
|
|
105
143
|
"""Authenticate with the first source whose credential is present."""
|
|
106
144
|
if scopes:
|
|
107
145
|
if self._optional:
|
|
@@ -131,12 +169,9 @@ class MultiAuth:
|
|
|
131
169
|
|
|
132
170
|
def require(self, **kwargs: Any) -> "MultiAuth":
|
|
133
171
|
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source."""
|
|
134
|
-
|
|
172
|
+
return self._with_sources(
|
|
135
173
|
cast(Any, source).require(**kwargs)
|
|
136
174
|
if hasattr(source, "require")
|
|
137
175
|
else source
|
|
138
176
|
for source in self._sources
|
|
139
177
|
)
|
|
140
|
-
clone = MultiAuth(*new_sources)
|
|
141
|
-
clone._optional = self._optional
|
|
142
|
-
return clone
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from collections.abc import Callable
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
-
from fastapi import
|
|
6
|
+
from fastapi.requests import HTTPConnection
|
|
7
7
|
from fastapi.security import APIKeyQuery
|
|
8
8
|
|
|
9
9
|
from ..abc import ValidatedAuthSource
|
|
@@ -49,6 +49,6 @@ class APIKeyQueryAuth(ValidatedAuthSource):
|
|
|
49
49
|
**kwargs,
|
|
50
50
|
)
|
|
51
51
|
|
|
52
|
-
async def extract(self, request:
|
|
52
|
+
async def extract(self, request: HTTPConnection) -> str | None:
|
|
53
53
|
"""Return the API key from the configured query parameter, or ``None`` when absent or empty."""
|
|
54
54
|
return request.query_params.get(self._name) or None
|
|
@@ -163,10 +163,22 @@ def _auth_units(dependant: Dependant) -> list[tuple[SchemeRequirement, ...]]:
|
|
|
163
163
|
return list(dict.fromkeys(requirements))
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
def _effective_route(context: Any) -> Any:
|
|
167
|
+
"""Return *context*, or the merged route 0.137.2 to 0.143.0 leave off it."""
|
|
168
|
+
if getattr(context, "dependant", None) is not None:
|
|
169
|
+
return context
|
|
170
|
+
effective = getattr(
|
|
171
|
+
getattr(context, "_route_context", None), "starlette_route", None
|
|
172
|
+
)
|
|
173
|
+
return context if effective is None else effective
|
|
174
|
+
|
|
175
|
+
|
|
166
176
|
def _routes_of(app: FastAPI) -> Iterable[Any]:
|
|
167
|
-
"""Yield the app's routes, expanding the lazily included routers of 0.
|
|
177
|
+
"""Yield the app's routes, expanding the lazily included routers of 0.137.2+."""
|
|
168
178
|
expand = getattr(routing, "iter_route_contexts", None)
|
|
169
|
-
|
|
179
|
+
if expand is None:
|
|
180
|
+
return app.routes
|
|
181
|
+
return (_effective_route(context) for context in expand(app.routes))
|
|
170
182
|
|
|
171
183
|
|
|
172
184
|
def auth_surface(app: FastAPI) -> list[RouteAuth]:
|
|
@@ -4,11 +4,15 @@ import functools
|
|
|
4
4
|
import hashlib
|
|
5
5
|
import hmac
|
|
6
6
|
import inspect
|
|
7
|
+
import math
|
|
8
|
+
import time
|
|
7
9
|
from collections.abc import Callable
|
|
10
|
+
from datetime import UTC, datetime
|
|
8
11
|
from typing import Any
|
|
9
12
|
|
|
10
13
|
import anyio.to_thread
|
|
11
|
-
from fastapi import HTTPException
|
|
14
|
+
from fastapi import HTTPException
|
|
15
|
+
from fastapi.requests import HTTPConnection
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
def hash_token(token: str) -> str:
|
|
@@ -70,7 +74,38 @@ def add_challenge(exc: HTTPException, challenge: str | None) -> None:
|
|
|
70
74
|
exc.headers = headers
|
|
71
75
|
|
|
72
76
|
|
|
73
|
-
def
|
|
77
|
+
def credential_age(instant: Any) -> float | None:
|
|
78
|
+
"""Seconds since *instant*, or ``None`` when it is not a point in time.
|
|
79
|
+
|
|
80
|
+
Epoch seconds or a ``datetime`` (naive read as UTC). Anything else,
|
|
81
|
+
a missing or non-finite instant included, fails closed.
|
|
82
|
+
"""
|
|
83
|
+
if isinstance(instant, datetime):
|
|
84
|
+
if instant.tzinfo is None:
|
|
85
|
+
instant = instant.replace(tzinfo=UTC)
|
|
86
|
+
instant = instant.timestamp()
|
|
87
|
+
elif isinstance(instant, bool) or not isinstance(instant, (int, float)):
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
age = time.time() - instant
|
|
91
|
+
except OverflowError:
|
|
92
|
+
return None
|
|
93
|
+
return age if math.isfinite(age) else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def step_up_challenge(challenge: str | None, max_age: float) -> dict[str, str] | None:
|
|
97
|
+
"""The RFC 9470 step-up ``WWW-Authenticate`` header, ``None`` without a challenge."""
|
|
98
|
+
if not challenge:
|
|
99
|
+
return None
|
|
100
|
+
separator = ", " if " " in challenge else " "
|
|
101
|
+
return challenge_headers(
|
|
102
|
+
f'{challenge}{separator}error="insufficient_user_authentication", '
|
|
103
|
+
'error_description="More recent authentication is required", '
|
|
104
|
+
f'max_age="{max(1, int(max_age))}"'
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def authorization_credential(request: HTTPConnection, scheme: str) -> str | None:
|
|
74
109
|
"""Extract the value of an ``Authorization: <scheme> <value>`` header.
|
|
75
110
|
|
|
76
111
|
The scheme is matched case-insensitively (RFC 7235 §2.1). Returns ``None``
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|