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.
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/PKG-INFO +8 -6
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/README.md +4 -2
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/pyproject.toml +9 -6
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/pyproject.toml.orig +6 -6
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/__init__.py +7 -2
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/abc.py +221 -37
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/exceptions.py +31 -0
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/basic.py +12 -4
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/bearer.py +32 -8
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/cookie.py +32 -7
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/header.py +2 -2
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/multi.py +48 -11
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/query.py +2 -2
- fastapi_multiauth-0.7.0/src/fastapi_multiauth/surface.py +217 -0
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/utils.py +37 -2
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/LICENSE +0 -0
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/_http.py +0 -0
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/_imports.py +0 -0
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/jwt.py +0 -0
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/client.py +0 -0
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/oauth/utils.py +0 -0
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/py.typed +0 -0
- {fastapi_multiauth-0.5.0 → fastapi_multiauth-0.7.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
- {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.
|
|
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.
|
|
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
|
---
|
|
@@ -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
|
[](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
|
---
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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,
|
|
@@ -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.
|
|
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
|
|
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=[...])",
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
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:
|
|
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=
|
|
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:
|
|
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(
|
|
182
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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:
|
|
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(
|
|
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 ``
|
|
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,
|
|
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(
|
|
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.
|
|
462
|
+
return await self._call_scoped(credential, [], request=request)
|
|
285
463
|
|
|
286
|
-
async def authenticate_scoped(
|
|
287
|
-
|
|
288
|
-
|
|
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) ->
|
|
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)
|