fastapi-multiauth 0.5.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.5.0 → fastapi_multiauth-0.7.0}/PKG-INFO +8 -6
  2. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/README.md +4 -2
  3. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/pyproject.toml +9 -6
  4. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/pyproject.toml.orig +6 -6
  5. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/__init__.py +7 -2
  6. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/abc.py +221 -37
  7. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/exceptions.py +31 -0
  8. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/basic.py +12 -4
  9. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/bearer.py +32 -8
  10. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/cookie.py +32 -7
  11. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/header.py +2 -2
  12. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/multi.py +48 -11
  13. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/query.py +2 -2
  14. fastapi_multiauth-0.7.0/src/fastapi_multiauth/surface.py +217 -0
  15. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/utils.py +37 -2
  16. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/LICENSE +0 -0
  17. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/_http.py +0 -0
  18. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/_imports.py +0 -0
  19. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/jwt.py +0 -0
  20. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
  21. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/client.py +0 -0
  22. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/utils.py +0 -0
  23. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/py.typed +0 -0
  24. {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
  25. {fastapi_multiauth-0.5.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.5.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.108.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
  ---
@@ -118,7 +118,7 @@ async def me(user=Security(auth)):
118
118
  - **BYO validator**: every source wraps a sync or async callable you provide; the library never touches your user model or database.
119
119
  - **`MultiAuth`**: try several sources in order on a single route (e.g. web session cookie + API bearer token), with all schemes documented in OpenAPI.
120
120
  - **Built-in sources**: covering the standard `fastapi.security` schemes:
121
- - **`HTTPBearerAuth`**: bearer tokens with optional Stripe-style `user_`/`org_` prefixes to route different token types to different validators, plus `generate_token()` for secure token creation.
121
+ - **`HTTPBearerAuth`**: bearer tokens with optional Stripe-style `user_`/`org_` prefixes to route different token types to different validators, plus `generate_token()` for secure token creation (bring your own `token_generator` to pick the token format).
122
122
  - **`APIKeyCookieAuth`**: cookie sessions with optional HMAC-SHA256 signing (via [itsdangerous](https://itsdangerous.palletsprojects.com/)), embedded expiry, key rotation, and optional per-session ids so your app can list and revoke sessions individually.
123
123
  - **`APIKeyHeaderAuth`**: `X-API-Key`-style schemes, with **`APIKeyQueryAuth`** for legacy clients that can only pass a query parameter.
124
124
  - **`HTTPBasicAuth`**: `validator(username, password)` with `WWW-Authenticate` realm support.
@@ -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
  ---
@@ -77,7 +77,7 @@ async def me(user=Security(auth)):
77
77
  - **BYO validator**: every source wraps a sync or async callable you provide; the library never touches your user model or database.
78
78
  - **`MultiAuth`**: try several sources in order on a single route (e.g. web session cookie + API bearer token), with all schemes documented in OpenAPI.
79
79
  - **Built-in sources**: covering the standard `fastapi.security` schemes:
80
- - **`HTTPBearerAuth`**: bearer tokens with optional Stripe-style `user_`/`org_` prefixes to route different token types to different validators, plus `generate_token()` for secure token creation.
80
+ - **`HTTPBearerAuth`**: bearer tokens with optional Stripe-style `user_`/`org_` prefixes to route different token types to different validators, plus `generate_token()` for secure token creation (bring your own `token_generator` to pick the token format).
81
81
  - **`APIKeyCookieAuth`**: cookie sessions with optional HMAC-SHA256 signing (via [itsdangerous](https://itsdangerous.palletsprojects.com/)), embedded expiry, key rotation, and optional per-session ids so your app can list and revoke sessions individually.
82
82
  - **`APIKeyHeaderAuth`**: `X-API-Key`-style schemes, with **`APIKeyQueryAuth`** for legacy clients that can only pass a query parameter.
83
83
  - **`HTTPBasicAuth`**: `validator(username, password)` with `WWW-Authenticate` realm support.
@@ -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.5.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.108.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.5.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.108.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,
@@ -24,9 +24,10 @@ from .sources import (
24
24
  OAuth2PasswordBearerAuth,
25
25
  OpenIdConnectAuth,
26
26
  )
27
+ from .surface import RouteAuth, SchemeRequirement, auth_surface
27
28
  from .utils import hash_token, verify_token_hash
28
29
 
29
- __version__ = "0.5.0"
30
+ __version__ = "0.7.0"
30
31
 
31
32
  __all__ = [
32
33
  "APIKeyCookieAuth",
@@ -41,7 +42,11 @@ __all__ = [
41
42
  "OAuth2AuthorizationCodeBearerAuth",
42
43
  "OAuth2PasswordBearerAuth",
43
44
  "OpenIdConnectAuth",
45
+ "RouteAuth",
46
+ "SchemeRequirement",
47
+ "StaleCredentialError",
44
48
  "UnauthorizedError",
49
+ "auth_surface",
45
50
  "hash_token",
46
51
  "oauth",
47
52
  "verify_token_hash",
@@ -2,24 +2,30 @@
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
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=[...])",
22
27
  "session_id": "injected by APIKeyCookieAuth(session_id=True)",
28
+ "request": "injected when the validator declares a 'request' parameter",
23
29
  }
24
30
 
25
31
 
@@ -33,11 +39,20 @@ def _reject_reserved_kwargs(kwargs: dict[str, Any]) -> None:
33
39
  )
34
40
 
35
41
 
36
- def _accepts_kwarg(fn: Callable[..., Any], name: str) -> bool:
37
- """Return whether *fn* declares a *name* parameter."""
42
+ def _signature_params(
43
+ fn: Callable[..., Any],
44
+ ) -> Mapping[str, inspect.Parameter] | None:
45
+ """The parameters of *fn*, ``None`` when it has no inspectable signature."""
38
46
  try:
39
- parameters = inspect.signature(fn).parameters
47
+ return inspect.signature(fn).parameters
40
48
  except (TypeError, ValueError):
49
+ return None
50
+
51
+
52
+ def _accepts_kwarg(fn: Callable[..., Any], name: str) -> bool:
53
+ """Return whether *fn* declares a *name* parameter, ``False`` when uninspectable."""
54
+ parameters = _signature_params(fn)
55
+ if parameters is None:
41
56
  return False
42
57
  param = parameters.get(name)
43
58
  return param is not None and param.kind in (
@@ -46,6 +61,21 @@ def _accepts_kwarg(fn: Callable[..., Any], name: str) -> bool:
46
61
  )
47
62
 
48
63
 
64
+ def _override_accepts_request(method: Callable[..., Any]) -> bool:
65
+ """Whether an ``authenticate_scoped`` override can be handed the request."""
66
+ parameters = _signature_params(method)
67
+ if parameters is None: # not introspectable: take the override at its word
68
+ return True
69
+ return any(
70
+ (
71
+ param.name == "request"
72
+ and param.kind is not inspect.Parameter.POSITIONAL_ONLY
73
+ )
74
+ or param.kind is inspect.Parameter.VAR_KEYWORD
75
+ for param in parameters.values()
76
+ )
77
+
78
+
49
79
  def _unenforceable_scopes_error(
50
80
  owner: object, scopes: list[str], reason: str | None = None
51
81
  ) -> RuntimeError:
@@ -76,6 +106,14 @@ def _anonymous_scopes_error(owner: object, scopes: list[str]) -> RuntimeError:
76
106
  )
77
107
 
78
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
+
79
117
  def _undeclared_scopes_error(
80
118
  owner: object, unknown: list[str], catalogue: dict[str, str]
81
119
  ) -> RuntimeError:
@@ -99,7 +137,7 @@ class _DocOnlyScheme(SecurityBase):
99
137
  self.model = scheme.model
100
138
  self.scheme_name = scheme.scheme_name
101
139
 
102
- async def __call__(self, request: Request) -> None:
140
+ async def __call__(self, request: HTTPConnection) -> None:
103
141
  return None
104
142
 
105
143
 
@@ -121,18 +159,30 @@ def _carry_scheme(dependency: Any, scheme: SecurityBase) -> None:
121
159
  dependency.scheme_name = scheme.scheme_name
122
160
 
123
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
+
124
172
  class AuthSource(ABC):
125
173
  """Abstract base class for authentication sources.
126
174
 
127
175
  Subclasses implement :meth:`extract` and :meth:`authenticate`; both
128
176
  ``Security(source)`` and ``MultiAuth`` route through that pair via
129
- :meth:`dispatch`.
177
+ :meth:`dispatch`. Optional hooks: :meth:`www_authenticate` for the 401
178
+ challenge, :meth:`authenticated_at` for :meth:`fresh`.
130
179
  """
131
180
 
132
181
  scheme: SecurityBase | None
133
182
  _optional: bool = False
134
183
  _scope_catalogue: dict[str, str] | None = None
135
184
  """The scopes this source publishes to OpenAPI, ``None`` when it has none."""
185
+ _freshness: _Freshness | None = None
136
186
 
137
187
  def __init__(self, scheme: Any = None) -> None:
138
188
  """Set up the FastAPI dependency signature.
@@ -141,6 +191,9 @@ class AuthSource(ABC):
141
191
  scheme: Optional ``fastapi.security`` scheme; only its OpenAPI
142
192
  metadata is used, extraction always goes through :meth:`extract`.
143
193
  """
194
+ self._scoped_takes_request = _override_accepts_request(
195
+ type(self).authenticate_scoped
196
+ )
144
197
  self.scheme = _DocOnlyScheme(scheme) if scheme is not None else None
145
198
  if self.scheme is not None:
146
199
  _carry_scheme(self, self.scheme)
@@ -149,7 +202,7 @@ class AuthSource(ABC):
149
202
  inspect.Parameter(
150
203
  "request",
151
204
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
152
- annotation=Request,
205
+ annotation=HTTPConnection,
153
206
  ),
154
207
  inspect.Parameter(
155
208
  "security_scopes",
@@ -160,7 +213,7 @@ class AuthSource(ABC):
160
213
  self.__signature__ = inspect.Signature(parameters, return_annotation=Any)
161
214
 
162
215
  @abstractmethod
163
- async def extract(self, request: Request) -> str | None:
216
+ async def extract(self, request: HTTPConnection) -> str | None:
164
217
  """Extract the raw credential from the request without validating.
165
218
 
166
219
  Must return ``None`` — never an empty string — when the credential is
@@ -178,8 +231,21 @@ class AuthSource(ABC):
178
231
  """
179
232
  return None
180
233
 
181
- async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
182
- """Validate a credential, enforcing the scopes declared on the route."""
234
+ async def authenticate_scoped(
235
+ self,
236
+ credential: str,
237
+ scopes: list[str],
238
+ *,
239
+ request: HTTPConnection | None = None,
240
+ ) -> Any:
241
+ """Validate a credential, enforcing the scopes declared on the route.
242
+
243
+ Args:
244
+ credential: The raw credential returned by :meth:`extract`.
245
+ scopes: The scopes the route declared, empty outside a route.
246
+ request: The request being authenticated, ``None`` when there is
247
+ none. Ignored here; override to use it.
248
+ """
183
249
  if scopes: # fail closed: plain authenticate() cannot check scopes
184
250
  raise _unenforceable_scopes_error(self, scopes)
185
251
  return await self.authenticate(credential)
@@ -188,22 +254,125 @@ class AuthSource(ABC):
188
254
  """Whether route-declared scopes reach an actual check in this source."""
189
255
  return type(self).authenticate_scoped is not AuthSource.authenticate_scoped
190
256
 
257
+ async def _call_scoped(
258
+ self, credential: str, scopes: list[str], *, request: HTTPConnection | None
259
+ ) -> Any:
260
+ """Call :meth:`authenticate_scoped`, honouring overrides predating ``request``."""
261
+ if self._scoped_takes_request:
262
+ return await self.authenticate_scoped(credential, scopes, request=request)
263
+ return await self.authenticate_scoped(credential, scopes)
264
+
191
265
  async def _authenticate_with_challenge(
192
- self, credential: str, scopes: list[str]
266
+ self,
267
+ credential: str,
268
+ scopes: list[str],
269
+ *,
270
+ request: HTTPConnection | None = None,
193
271
  ) -> Any:
194
- """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
+ """
195
277
  try:
196
- return await self.authenticate_scoped(credential, scopes)
278
+ identity = await self._call_scoped(credential, scopes, request=request)
197
279
  except HTTPException as exc:
198
280
  add_challenge(exc, self.www_authenticate())
199
281
  raise
282
+ await self._enforce_freshness(credential, identity)
283
+ return identity
284
+
285
+ def optional(self) -> Self:
286
+ """Return a copy that yields ``None`` instead of 401 when no credential is sent.
200
287
 
201
- def optional(self) -> "Self":
202
- """Return a copy that yields ``None`` instead of 401 when no credential is sent."""
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)
203
293
  clone = copy.copy(self)
204
294
  clone._optional = True
205
295
  return clone
206
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
+
207
376
  def _reject_undeclared_scopes(self, scopes: list[str]) -> None:
208
377
  """Refuse a route declaring scopes this source never published."""
209
378
  catalogue = self._scope_catalogue
@@ -213,11 +382,9 @@ class AuthSource(ABC):
213
382
  if unknown:
214
383
  raise _undeclared_scopes_error(self, unknown, catalogue)
215
384
 
216
- async def dispatch(self, request: Request, scopes: list[str]) -> Any:
385
+ async def dispatch(self, request: HTTPConnection, scopes: list[str]) -> Any:
217
386
  """Extract the credential, then authenticate it with the route scopes.
218
387
 
219
- Returns ``None`` for an absent credential when :meth:`optional` was used.
220
-
221
388
  Raises:
222
389
  UnauthorizedError: When no credential is present. This source's
223
390
  ``WWW-Authenticate`` challenge is attached to any 401 raised.
@@ -233,7 +400,9 @@ class AuthSource(ABC):
233
400
  if self._optional:
234
401
  return None
235
402
  raise UnauthorizedError(headers=challenge_headers(self.www_authenticate()))
236
- return await self._authenticate_with_challenge(credential, scopes)
403
+ return await self._authenticate_with_challenge(
404
+ credential, scopes, request=request
405
+ )
237
406
 
238
407
  async def __call__(self, **kwargs: Any) -> Any:
239
408
  """FastAPI dependency dispatch."""
@@ -261,40 +430,55 @@ class ValidatedAuthSource(AuthSource):
261
430
  scheme: Optional ``fastapi.security`` scheme for OpenAPI.
262
431
  **kwargs: Extra keyword arguments forwarded to the validator on
263
432
  every call. Names the library injects itself are reserved:
264
- ``scopes`` and ``session_id``.
433
+ ``scopes``, ``session_id`` and ``request``.
265
434
  """
266
435
  _reject_reserved_kwargs(kwargs)
267
436
  self._validator = ensure_async(validator)
268
437
  self._accepts_scopes = _accepts_kwarg(validator, "scopes")
438
+ self._accepts_request = _accepts_kwarg(validator, "request")
269
439
  self._kwargs = kwargs
270
440
  super().__init__(scheme)
271
441
 
272
442
  async def _call_validator(
273
- self, *args: Any, scopes: list[str], **injected: Any
443
+ self,
444
+ *args: Any,
445
+ scopes: list[str],
446
+ request: HTTPConnection | None = None,
447
+ **injected: Any,
274
448
  ) -> Any:
275
- """Invoke the validator with scope and configured kwargs forwarding."""
449
+ """Invoke the validator with scope, request and configured kwargs forwarding."""
276
450
  if self._accepts_scopes:
277
451
  injected["scopes"] = scopes
278
452
  elif scopes: # fail closed: the validator cannot check them
279
453
  raise _unenforceable_scopes_error(self, scopes)
454
+ if self._accepts_request:
455
+ injected["request"] = request
280
456
  return await self._validator(*args, **self._kwargs, **injected)
281
457
 
282
- async def authenticate(self, credential: str) -> Any:
458
+ async def authenticate(
459
+ self, credential: str, *, request: HTTPConnection | None = None
460
+ ) -> Any:
283
461
  """Validate a credential and return the identity (no route scopes)."""
284
- return await self.authenticate_scoped(credential, [])
462
+ return await self._call_scoped(credential, [], request=request)
285
463
 
286
- async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
287
- """Validate a credential, forwarding route-declared scopes to the validator."""
288
- return await self._call_validator(credential, scopes=scopes)
464
+ async def authenticate_scoped(
465
+ self,
466
+ credential: str,
467
+ scopes: list[str],
468
+ *,
469
+ request: HTTPConnection | None = None,
470
+ ) -> Any:
471
+ """Validate a credential, forwarding route scopes and request to the validator."""
472
+ return await self._call_validator(credential, scopes=scopes, request=request)
289
473
 
290
474
  def _enforces_scopes(self) -> bool:
291
475
  """The validator is the authority: every source funnels into it."""
292
476
  return self._accepts_scopes
293
477
 
294
- def require(self, **kwargs: Any) -> "Self":
478
+ def require(self, **kwargs: Any) -> Self:
295
479
  """Return a copy of this source with additional (or overriding) validator kwargs.
296
480
 
297
- Reserved names (``scopes``, ``session_id``) are rejected here too.
481
+ Reserved names (``scopes``, ``session_id``, ``request``) are rejected here too.
298
482
  """
299
483
  _reject_reserved_kwargs(kwargs)
300
484
  clone = copy.copy(self)