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.
Files changed (25) hide show
  1. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/PKG-INFO +7 -5
  2. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/README.md +3 -1
  3. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/pyproject.toml +9 -6
  4. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/pyproject.toml.orig +6 -6
  5. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/__init__.py +3 -2
  6. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/abc.py +153 -25
  7. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/exceptions.py +31 -0
  8. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/basic.py +7 -3
  9. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/bearer.py +7 -3
  10. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/cookie.py +26 -5
  11. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/header.py +2 -2
  12. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/multi.py +45 -10
  13. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/query.py +2 -2
  14. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/surface.py +14 -2
  15. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/utils.py +37 -2
  16. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/LICENSE +0 -0
  17. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/_http.py +0 -0
  18. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/_imports.py +0 -0
  19. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/jwt.py +0 -0
  20. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
  21. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/client.py +0 -0
  22. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/utils.py +0 -0
  23. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/py.typed +0 -0
  24. {fastapi_multiauth-0.6.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
  25. {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.6.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.10
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
  [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
50
50
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
51
51
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
52
- [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
52
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
53
53
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
  [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
9
9
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
10
10
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
11
- [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
11
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.6.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.10"
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.13.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 = ["S"]
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.6.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.10"
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.13.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.6.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 typing import TYPE_CHECKING, Any
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, Request
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 add_challenge, challenge_headers, ensure_async
15
-
16
- if TYPE_CHECKING:
17
- from typing_extensions import Self
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: Request) -> None:
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=Request,
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: Request) -> str | None:
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, credential: str, scopes: list[str], *, request: Request | None = None
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: Request | None
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, credential: str, scopes: list[str], *, request: Request | None = None
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
- return await self._call_scoped(credential, scopes, request=request)
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) -> "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: Request, scopes: list[str]) -> Any:
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: Request | None = None,
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: Request | None = None
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, credential: str, scopes: list[str], *, request: Request | None = None
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) -> "Self":
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 Request
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: Request) -> str | None:
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, credential: str, scopes: list[str], *, request: Request | None = None
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 Request
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: Request) -> str | None:
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, credential: str, scopes: list[str], *, request: Request | None = None
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 Request, Response
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: Request) -> str | None:
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: Request) -> str | None:
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, credential: str, scopes: list[str], *, request: Request | None = None
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: Request | None = None
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 Request
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: Request) -> str | None:
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 typing import Annotated, Any, cast
5
+ from collections.abc import Callable, Iterable
6
+ from typing import Annotated, Any, Self, cast
6
7
 
7
- from fastapi import Depends, Request
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=Request,
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) -> "MultiAuth":
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
- async def dispatch(self, request: Request, scopes: list[str]) -> Any:
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
- new_sources = tuple(
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 Request
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: Request) -> str | None:
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.141+."""
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
- return expand(app.routes) if expand else app.routes
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, Request
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 authorization_credential(request: Request, scheme: str) -> str | None:
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``