fastapi-multiauth 0.7.0__tar.gz → 0.8.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.7.0 → fastapi_multiauth-0.8.0}/PKG-INFO +5 -4
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/README.md +4 -3
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/pyproject.toml +1 -1
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/pyproject.toml.orig +1 -1
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/__init__.py +22 -3
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/abc.py +168 -24
- fastapi_multiauth-0.8.0/src/fastapi_multiauth/amr.py +54 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/exceptions.py +49 -9
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/jwt.py +240 -115
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/oauth/__init__.py +10 -1
- fastapi_multiauth-0.8.0/src/fastapi_multiauth/oauth/id_token.py +143 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/oauth/utils.py +74 -34
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/basic.py +3 -3
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/cookie.py +30 -3
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/multi.py +47 -7
- fastapi_multiauth-0.8.0/src/fastapi_multiauth/utils.py +259 -0
- fastapi_multiauth-0.7.0/src/fastapi_multiauth/utils.py +0 -120
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/LICENSE +0 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/_http.py +0 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/_imports.py +0 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/oauth/client.py +0 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/py.typed +0 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/bearer.py +0 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/header.py +0 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/oauth2.py +0 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/query.py +0 -0
- {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/surface.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-multiauth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -123,13 +123,14 @@ async def me(user=Security(auth)):
|
|
|
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.
|
|
125
125
|
- **`OAuth2AuthorizationCodeBearerAuth`**, **`OAuth2PasswordBearerAuth`**, **`OpenIdConnectAuth`**: validate tokens an OAuth 2.0 / OIDC provider issued, and publish the scope catalogue so `/docs` offers a real Authorize dialog.
|
|
126
|
-
- **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
|
|
126
|
+
- **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison, and `checksum_token_generator`/`verify_token_checksum` add a GitHub-style CRC32 suffix that refuses typos before the lookup.
|
|
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
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.
|
|
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. `source.stepped_up(acr_values=[...], acr=...)` asks for a stronger level instead, and composes with `fresh()` into one challenge; `AuthenticationMethodReference` names the [RFC 8176](https://datatracker.ietf.org/doc/html/rfc8176) `amr` values.
|
|
131
131
|
- **Correct HTTP semantics**: 401 with `WWW-Authenticate` challenges ([RFC 7235](https://datatracker.ietf.org/doc/html/rfc7235)) and 403 via `ForbiddenError`.
|
|
132
|
-
- **
|
|
132
|
+
- **Security-event logging**: every refusal is a `WARNING` under `fastapi_multiauth.*` with a reason code (`invalid_signature`, `expired`, `stale`, …) in `extra`, never the credential or the identity; silent until your app configures logging.
|
|
133
|
+
- **OAuth 2.0 / OIDC helpers** (`fastapi-multiauth[oauth]` extra): async discovery with TTL caching, HTTPS enforcement, CSRF-protected `state` encoding, PKCE (S256), code exchange delegated to [httpx-oauth](https://github.com/frankie567/httpx-oauth), and OpenID Connect ID token validation with `nonce` and `at_hash` (plus the `jwt` extra).
|
|
133
134
|
|
|
134
135
|
## Comparison
|
|
135
136
|
|
|
@@ -82,13 +82,14 @@ async def me(user=Security(auth)):
|
|
|
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.
|
|
84
84
|
- **`OAuth2AuthorizationCodeBearerAuth`**, **`OAuth2PasswordBearerAuth`**, **`OpenIdConnectAuth`**: validate tokens an OAuth 2.0 / OIDC provider issued, and publish the scope catalogue so `/docs` offers a real Authorize dialog.
|
|
85
|
-
- **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
|
|
85
|
+
- **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison, and `checksum_token_generator`/`verify_token_checksum` add a GitHub-style CRC32 suffix that refuses typos before the lookup.
|
|
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
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.
|
|
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. `source.stepped_up(acr_values=[...], acr=...)` asks for a stronger level instead, and composes with `fresh()` into one challenge; `AuthenticationMethodReference` names the [RFC 8176](https://datatracker.ietf.org/doc/html/rfc8176) `amr` values.
|
|
90
90
|
- **Correct HTTP semantics**: 401 with `WWW-Authenticate` challenges ([RFC 7235](https://datatracker.ietf.org/doc/html/rfc7235)) and 403 via `ForbiddenError`.
|
|
91
|
-
- **
|
|
91
|
+
- **Security-event logging**: every refusal is a `WARNING` under `fastapi_multiauth.*` with a reason code (`invalid_signature`, `expired`, `stale`, …) in `extra`, never the credential or the identity; silent until your app configures logging.
|
|
92
|
+
- **OAuth 2.0 / OIDC helpers** (`fastapi-multiauth[oauth]` extra): async discovery with TTL caching, HTTPS enforcement, CSRF-protected `state` encoding, PKCE (S256), code exchange delegated to [httpx-oauth](https://github.com/frankie567/httpx-oauth), and OpenID Connect ID token validation with `nonce` and `at_hash` (plus the `jwt` extra).
|
|
92
93
|
|
|
93
94
|
## Comparison
|
|
94
95
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-multiauth"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.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"
|
|
@@ -9,9 +9,17 @@ exported here, and require the ``oauth`` extra. Import them explicitly::
|
|
|
9
9
|
redirect = oauth.oauth_build_authorization_redirect(...)
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import logging
|
|
13
|
+
|
|
12
14
|
from . import oauth
|
|
13
15
|
from .abc import AuthSource
|
|
14
|
-
from .
|
|
16
|
+
from .amr import AuthenticationMethodReference
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
ForbiddenError,
|
|
19
|
+
InsufficientAuthenticationError,
|
|
20
|
+
StaleCredentialError,
|
|
21
|
+
UnauthorizedError,
|
|
22
|
+
)
|
|
15
23
|
from .jwt import JWTValidator
|
|
16
24
|
from .sources import (
|
|
17
25
|
APIKeyCookieAuth,
|
|
@@ -25,18 +33,27 @@ from .sources import (
|
|
|
25
33
|
OpenIdConnectAuth,
|
|
26
34
|
)
|
|
27
35
|
from .surface import RouteAuth, SchemeRequirement, auth_surface
|
|
28
|
-
from .utils import
|
|
36
|
+
from .utils import (
|
|
37
|
+
checksum_token_generator,
|
|
38
|
+
hash_token,
|
|
39
|
+
verify_token_checksum,
|
|
40
|
+
verify_token_hash,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = "0.8.0"
|
|
29
44
|
|
|
30
|
-
|
|
45
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
31
46
|
|
|
32
47
|
__all__ = [
|
|
33
48
|
"APIKeyCookieAuth",
|
|
34
49
|
"APIKeyHeaderAuth",
|
|
35
50
|
"APIKeyQueryAuth",
|
|
36
51
|
"AuthSource",
|
|
52
|
+
"AuthenticationMethodReference",
|
|
37
53
|
"ForbiddenError",
|
|
38
54
|
"HTTPBasicAuth",
|
|
39
55
|
"HTTPBearerAuth",
|
|
56
|
+
"InsufficientAuthenticationError",
|
|
40
57
|
"JWTValidator",
|
|
41
58
|
"MultiAuth",
|
|
42
59
|
"OAuth2AuthorizationCodeBearerAuth",
|
|
@@ -47,7 +64,9 @@ __all__ = [
|
|
|
47
64
|
"StaleCredentialError",
|
|
48
65
|
"UnauthorizedError",
|
|
49
66
|
"auth_surface",
|
|
67
|
+
"checksum_token_generator",
|
|
50
68
|
"hash_token",
|
|
51
69
|
"oauth",
|
|
70
|
+
"verify_token_checksum",
|
|
52
71
|
"verify_token_hash",
|
|
53
72
|
]
|
|
@@ -2,26 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
4
|
import inspect
|
|
5
|
+
import logging
|
|
5
6
|
import math
|
|
6
7
|
from abc import ABC, abstractmethod
|
|
7
|
-
from collections.abc import Awaitable, Callable, Mapping
|
|
8
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
8
9
|
from dataclasses import dataclass
|
|
9
|
-
from typing import Any, Self
|
|
10
|
+
from typing import Any, ClassVar, Self
|
|
10
11
|
|
|
11
12
|
from fastapi import HTTPException
|
|
12
13
|
from fastapi.requests import HTTPConnection
|
|
13
14
|
from fastapi.security import SecurityScopes
|
|
14
15
|
from fastapi.security.base import SecurityBase
|
|
15
16
|
|
|
16
|
-
from fastapi_multiauth.exceptions import
|
|
17
|
+
from fastapi_multiauth.exceptions import (
|
|
18
|
+
InsufficientAuthenticationError,
|
|
19
|
+
StaleCredentialError,
|
|
20
|
+
UnauthorizedError,
|
|
21
|
+
)
|
|
17
22
|
from fastapi_multiauth.utils import (
|
|
18
23
|
add_challenge,
|
|
19
24
|
challenge_headers,
|
|
20
25
|
credential_age,
|
|
21
26
|
ensure_async,
|
|
27
|
+
log_decision,
|
|
28
|
+
refusal,
|
|
29
|
+
refusal_reason,
|
|
22
30
|
step_up_challenge,
|
|
23
31
|
)
|
|
24
32
|
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
25
35
|
_RESERVED_KWARGS = {
|
|
26
36
|
"scopes": "injected from the route by Security(..., scopes=[...])",
|
|
27
37
|
"session_id": "injected by APIKeyCookieAuth(session_id=True)",
|
|
@@ -106,14 +116,46 @@ def _anonymous_scopes_error(owner: object, scopes: list[str]) -> RuntimeError:
|
|
|
106
116
|
)
|
|
107
117
|
|
|
108
118
|
|
|
109
|
-
def
|
|
110
|
-
"""Error for a dependency asking for both anonymous callers and
|
|
119
|
+
def _step_up_optional_error(owner: object, guard: str) -> ValueError:
|
|
120
|
+
"""Error for a dependency asking for both anonymous callers and a step-up."""
|
|
111
121
|
return ValueError(
|
|
112
|
-
f"{type(owner).__name__} cannot be both optional() and
|
|
113
|
-
"anonymous caller
|
|
122
|
+
f"{type(owner).__name__} cannot be both optional() and {guard}: an "
|
|
123
|
+
"anonymous caller has proven no factor at all"
|
|
114
124
|
)
|
|
115
125
|
|
|
116
126
|
|
|
127
|
+
def _step_up_optional_guard(owner: Any) -> str | None:
|
|
128
|
+
"""The step-up guard that keeps *owner* from being optional, if any."""
|
|
129
|
+
if owner._freshness is not None:
|
|
130
|
+
return "fresh()"
|
|
131
|
+
if owner._assurance is not None:
|
|
132
|
+
return "stepped_up()"
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _validate_acr_values(acr_values: Sequence[str]) -> tuple[str, ...]:
|
|
137
|
+
"""Check *acr_values* can travel in the step-up challenge's quoted string."""
|
|
138
|
+
if isinstance(acr_values, str):
|
|
139
|
+
raise TypeError("acr_values must be a sequence of strings, not a string")
|
|
140
|
+
values = tuple(acr_values)
|
|
141
|
+
if not values:
|
|
142
|
+
raise ValueError("acr_values must list at least one value")
|
|
143
|
+
for value in values:
|
|
144
|
+
if not (
|
|
145
|
+
isinstance(value, str)
|
|
146
|
+
and value
|
|
147
|
+
and value.isascii()
|
|
148
|
+
and value.isprintable()
|
|
149
|
+
and not any(char in value for char in ' "\\')
|
|
150
|
+
):
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"each acr_values entry must be non-empty printable US-ASCII "
|
|
153
|
+
f"without spaces, quotes or backslashes (got {value!r}): the "
|
|
154
|
+
"values are space-separated inside a quoted-string (RFC 9470)"
|
|
155
|
+
)
|
|
156
|
+
return values
|
|
157
|
+
|
|
158
|
+
|
|
117
159
|
def _undeclared_scopes_error(
|
|
118
160
|
owner: object, unknown: list[str], catalogue: dict[str, str]
|
|
119
161
|
) -> RuntimeError:
|
|
@@ -169,6 +211,15 @@ class _Freshness:
|
|
|
169
211
|
"""Caller-supplied reader of the instant, ``None`` to use the source's own."""
|
|
170
212
|
|
|
171
213
|
|
|
214
|
+
@dataclass(frozen=True, slots=True)
|
|
215
|
+
class _Assurance:
|
|
216
|
+
"""The levels set by :meth:`AuthSource.stepped_up`."""
|
|
217
|
+
|
|
218
|
+
acr_values: tuple[str, ...]
|
|
219
|
+
read: Callable[[Any], Awaitable[Any]]
|
|
220
|
+
"""Caller-supplied reader of the identity's ``acr``."""
|
|
221
|
+
|
|
222
|
+
|
|
172
223
|
class AuthSource(ABC):
|
|
173
224
|
"""Abstract base class for authentication sources.
|
|
174
225
|
|
|
@@ -183,6 +234,15 @@ class AuthSource(ABC):
|
|
|
183
234
|
_scope_catalogue: dict[str, str] | None = None
|
|
184
235
|
"""The scopes this source publishes to OpenAPI, ``None`` when it has none."""
|
|
185
236
|
_freshness: _Freshness | None = None
|
|
237
|
+
_assurance: _Assurance | None = None
|
|
238
|
+
_logger: ClassVar[logging.Logger] = logger
|
|
239
|
+
"""Where this source logs its decisions: its module's logger."""
|
|
240
|
+
|
|
241
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
242
|
+
"""Log a library source under its own module's logger."""
|
|
243
|
+
super().__init_subclass__(**kwargs)
|
|
244
|
+
if cls.__module__.startswith(f"{__package__}."):
|
|
245
|
+
cls._logger = logging.getLogger(cls.__module__)
|
|
186
246
|
|
|
187
247
|
def __init__(self, scheme: Any = None) -> None:
|
|
188
248
|
"""Set up the FastAPI dependency signature.
|
|
@@ -272,24 +332,40 @@ class AuthSource(ABC):
|
|
|
272
332
|
"""Authenticate, attaching this source's challenge to any 401 raised.
|
|
273
333
|
|
|
274
334
|
Freshness is checked after the credential: an invalid one is refused as
|
|
275
|
-
invalid, not as stale.
|
|
335
|
+
invalid, not as stale. Every outcome is logged.
|
|
276
336
|
"""
|
|
277
337
|
try:
|
|
278
338
|
identity = await self._call_scoped(credential, scopes, request=request)
|
|
339
|
+
await self._enforce_step_up(credential, identity)
|
|
279
340
|
except HTTPException as exc:
|
|
280
341
|
add_challenge(exc, self.www_authenticate())
|
|
342
|
+
self._log(logging.WARNING, refusal_reason(exc), exc.status_code)
|
|
281
343
|
raise
|
|
282
|
-
|
|
344
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
345
|
+
self._log(logging.DEBUG, "authenticated")
|
|
283
346
|
return identity
|
|
284
347
|
|
|
348
|
+
def _log(self, level: int, reason: str, status: int | None = None) -> None:
|
|
349
|
+
"""Log one decision of this source."""
|
|
350
|
+
log_decision(
|
|
351
|
+
self._logger,
|
|
352
|
+
level,
|
|
353
|
+
reason,
|
|
354
|
+
source=type(self).__name__,
|
|
355
|
+
scheme=self.scheme.scheme_name if self.scheme is not None else None,
|
|
356
|
+
status=status,
|
|
357
|
+
)
|
|
358
|
+
|
|
285
359
|
def optional(self) -> Self:
|
|
286
360
|
"""Return a copy that yields ``None`` instead of 401 when no credential is sent.
|
|
287
361
|
|
|
288
362
|
Raises:
|
|
289
|
-
ValueError: On a :meth:`fresh` copy:
|
|
363
|
+
ValueError: On a :meth:`fresh` or :meth:`stepped_up` copy:
|
|
364
|
+
anonymous has proven no factor.
|
|
290
365
|
"""
|
|
291
|
-
|
|
292
|
-
|
|
366
|
+
guard = _step_up_optional_guard(self)
|
|
367
|
+
if guard is not None:
|
|
368
|
+
raise _step_up_optional_error(self, guard)
|
|
293
369
|
clone = copy.copy(self)
|
|
294
370
|
clone._optional = True
|
|
295
371
|
return clone
|
|
@@ -336,7 +412,7 @@ class AuthSource(ABC):
|
|
|
336
412
|
if not math.isfinite(leeway) or leeway < 0:
|
|
337
413
|
raise ValueError("leeway must be a finite, non-negative number of seconds")
|
|
338
414
|
if self._optional:
|
|
339
|
-
raise
|
|
415
|
+
raise _step_up_optional_error(self, "fresh()")
|
|
340
416
|
if self._freshness is not None:
|
|
341
417
|
raise ValueError(
|
|
342
418
|
f"{type(self).__name__} already has a freshness window: call "
|
|
@@ -355,23 +431,89 @@ class AuthSource(ABC):
|
|
|
355
431
|
)
|
|
356
432
|
return clone
|
|
357
433
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
434
|
+
def stepped_up(
|
|
435
|
+
self,
|
|
436
|
+
acr_values: Sequence[str],
|
|
437
|
+
*,
|
|
438
|
+
acr: Callable[..., Any],
|
|
439
|
+
) -> Self:
|
|
440
|
+
"""Return a copy that refuses a credential not proven at one of *acr_values*.
|
|
441
|
+
|
|
442
|
+
The `RFC 9470 <https://datatracker.ietf.org/doc/html/rfc9470>`_
|
|
443
|
+
counterpart of :meth:`fresh`: "authenticate more strongly" rather than
|
|
444
|
+
"authenticate again". Both compose, in either order, into one
|
|
445
|
+
challenge carrying ``acr_values`` and ``max_age``.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
acr_values: The authentication context class references the route
|
|
449
|
+
accepts; any one of them passes.
|
|
450
|
+
acr: Sync or async callable taking the identity and returning the
|
|
451
|
+
``acr`` the caller authenticated with, e.g.
|
|
452
|
+
``lambda claims: claims.get("acr")``. Anything outside
|
|
453
|
+
*acr_values*, ``None`` included, refuses the request.
|
|
454
|
+
|
|
455
|
+
Raises:
|
|
456
|
+
TypeError: If *acr_values* is a bare string.
|
|
457
|
+
ValueError: An empty *acr_values*, an entry that cannot travel in
|
|
458
|
+
the challenge (whitespace, quotes, backslashes, non-ASCII), an
|
|
459
|
+
:meth:`optional` copy, or a copy that already has levels.
|
|
460
|
+
"""
|
|
461
|
+
values = _validate_acr_values(acr_values)
|
|
462
|
+
if self._optional:
|
|
463
|
+
raise _step_up_optional_error(self, "stepped_up()")
|
|
464
|
+
if self._assurance is not None:
|
|
465
|
+
raise ValueError(
|
|
466
|
+
f"{type(self).__name__} already has acr_values: call "
|
|
467
|
+
"stepped_up() once, on the source or on the MultiAuth"
|
|
468
|
+
)
|
|
469
|
+
clone = copy.copy(self)
|
|
470
|
+
clone._assurance = _Assurance(acr_values=values, read=ensure_async(acr))
|
|
471
|
+
return clone
|
|
472
|
+
|
|
473
|
+
async def _enforce_step_up(self, credential: str, identity: Any) -> None:
|
|
474
|
+
"""Refuse an identity outside the :meth:`fresh` window or :meth:`stepped_up` levels."""
|
|
475
|
+
freshness, assurance = self._freshness, self._assurance
|
|
476
|
+
if freshness is not None and not await self._is_fresh(
|
|
477
|
+
freshness, credential, identity
|
|
478
|
+
):
|
|
479
|
+
raise refusal(self._step_up_error(StaleCredentialError), "stale")
|
|
480
|
+
if assurance is not None:
|
|
481
|
+
achieved = await assurance.read(identity)
|
|
482
|
+
if achieved not in assurance.acr_values:
|
|
483
|
+
raise refusal(
|
|
484
|
+
self._step_up_error(InsufficientAuthenticationError),
|
|
485
|
+
"insufficient_acr",
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
async def _is_fresh(
|
|
489
|
+
self, freshness: _Freshness, credential: str, identity: Any
|
|
490
|
+
) -> bool:
|
|
491
|
+
"""Whether the credential was proven inside the window."""
|
|
363
492
|
if freshness.read is not None:
|
|
364
493
|
instant = await freshness.read(identity)
|
|
365
494
|
else:
|
|
366
495
|
instant = self.authenticated_at(credential)
|
|
367
496
|
age = credential_age(instant)
|
|
368
|
-
|
|
497
|
+
return age is not None and (
|
|
369
498
|
-freshness.leeway <= age <= freshness.max_age + freshness.leeway
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
def _step_up_error(
|
|
502
|
+
self, error_cls: type[InsufficientAuthenticationError]
|
|
503
|
+
) -> InsufficientAuthenticationError:
|
|
504
|
+
"""The refusal, its challenge listing every step-up requirement."""
|
|
505
|
+
max_age = self._freshness.max_age if self._freshness else None
|
|
506
|
+
acr_values = self._assurance.acr_values if self._assurance else None
|
|
507
|
+
return error_cls(
|
|
508
|
+
headers=step_up_challenge(
|
|
509
|
+
self.www_authenticate(),
|
|
510
|
+
error_cls.error_description,
|
|
511
|
+
max_age=max_age,
|
|
512
|
+
acr_values=acr_values,
|
|
513
|
+
),
|
|
514
|
+
max_age=max_age,
|
|
515
|
+
acr_values=acr_values,
|
|
516
|
+
)
|
|
375
517
|
|
|
376
518
|
def _reject_undeclared_scopes(self, scopes: list[str]) -> None:
|
|
377
519
|
"""Refuse a route declaring scopes this source never published."""
|
|
@@ -398,7 +540,9 @@ class AuthSource(ABC):
|
|
|
398
540
|
credential = await self.extract(request)
|
|
399
541
|
if credential is None:
|
|
400
542
|
if self._optional:
|
|
543
|
+
self._log(logging.DEBUG, "anonymous")
|
|
401
544
|
return None
|
|
545
|
+
self._log(logging.INFO, "missing_credential", 401)
|
|
402
546
|
raise UnauthorizedError(headers=challenge_headers(self.www_authenticate()))
|
|
403
547
|
return await self._authenticate_with_challenge(
|
|
404
548
|
credential, scopes, request=request
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Authentication Method Reference values (RFC 8176) for ``amr`` claims."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthenticationMethodReference(StrEnum):
|
|
7
|
+
"""The registered values of the ``amr`` claim (RFC 8176 §2, IANA registry).
|
|
8
|
+
|
|
9
|
+
A ``StrEnum``, so members compare equal to the strings a provider puts in
|
|
10
|
+
the claim: ``AuthenticationMethodReference.MULTI_FACTOR in claims["amr"]``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
FACE = "face"
|
|
14
|
+
"""Biometric authentication using facial recognition."""
|
|
15
|
+
FINGERPRINT = "fpt"
|
|
16
|
+
"""Biometric authentication using a fingerprint."""
|
|
17
|
+
GEOLOCATION = "geo"
|
|
18
|
+
"""Use of geolocation information for authentication."""
|
|
19
|
+
HARDWARE_KEY = "hwk"
|
|
20
|
+
"""Proof-of-possession of a hardware-secured key."""
|
|
21
|
+
IRIS = "iris"
|
|
22
|
+
"""Biometric authentication using an iris scan."""
|
|
23
|
+
KNOWLEDGE_BASED = "kba"
|
|
24
|
+
"""Knowledge-based authentication."""
|
|
25
|
+
MULTI_CHANNEL = "mca"
|
|
26
|
+
"""Multiple-channel authentication."""
|
|
27
|
+
MULTI_FACTOR = "mfa"
|
|
28
|
+
"""Multiple-factor authentication."""
|
|
29
|
+
ONE_TIME_PASSWORD = "otp" # noqa: S105
|
|
30
|
+
"""One-time password."""
|
|
31
|
+
PIN = "pin"
|
|
32
|
+
"""Personal Identification Number or pattern used to unlock a device."""
|
|
33
|
+
PROOF_OF_POSSESSION = "pop"
|
|
34
|
+
"""Proof-of-possession of a key (OpenID Connect EAP ACR Values 1.0)."""
|
|
35
|
+
PASSWORD = "pwd" # noqa: S105
|
|
36
|
+
"""Password-based authentication."""
|
|
37
|
+
RISK_BASED = "rba"
|
|
38
|
+
"""Risk-based authentication."""
|
|
39
|
+
RETINA = "retina"
|
|
40
|
+
"""Biometric authentication using a retina scan."""
|
|
41
|
+
SMART_CARD = "sc"
|
|
42
|
+
"""Smart card."""
|
|
43
|
+
SMS = "sms"
|
|
44
|
+
"""Confirmation by an SMS text message to a registered number."""
|
|
45
|
+
SOFTWARE_KEY = "swk"
|
|
46
|
+
"""Proof-of-possession of a software-secured key."""
|
|
47
|
+
TELEPHONE = "tel"
|
|
48
|
+
"""Confirmation by a telephone call to a registered number."""
|
|
49
|
+
USER_PRESENCE = "user"
|
|
50
|
+
"""User presence test."""
|
|
51
|
+
VOICE = "vbm"
|
|
52
|
+
"""Biometric authentication using a voiceprint."""
|
|
53
|
+
WINDOWS_INTEGRATED = "wia"
|
|
54
|
+
"""Windows integrated authentication."""
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Exceptions raised by authentication sources and OAuth helpers."""
|
|
2
2
|
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
3
5
|
from fastapi import HTTPException, status
|
|
4
6
|
|
|
5
7
|
|
|
@@ -25,18 +27,26 @@ class UnauthorizedError(HTTPException):
|
|
|
25
27
|
)
|
|
26
28
|
|
|
27
29
|
|
|
28
|
-
class
|
|
29
|
-
"""HTTP 401
|
|
30
|
+
class InsufficientAuthenticationError(UnauthorizedError):
|
|
31
|
+
"""HTTP 401: the credential is valid but its authentication falls short.
|
|
30
32
|
|
|
31
|
-
Raised by a route guarded with :meth:`~fastapi_multiauth.AuthSource.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
Raised by a route guarded with :meth:`~fastapi_multiauth.AuthSource.stepped_up`
|
|
34
|
+
when the credential was not proven at a requested level, and the base of
|
|
35
|
+
:class:`StaleCredentialError`. ``detail`` is the stable string
|
|
36
|
+
``"Insufficient user authentication"``, and sources with an HTTP auth
|
|
37
|
+
scheme carry the `RFC 9470 <https://datatracker.ietf.org/doc/html/rfc9470>`_
|
|
38
|
+
step-up challenge listing every requirement of the route. A handler
|
|
39
|
+
rendering its own body reads :attr:`max_age` and :attr:`acr_values`.
|
|
36
40
|
"""
|
|
37
41
|
|
|
42
|
+
error_description: ClassVar[str] = "A different authentication level is required"
|
|
43
|
+
"""The challenge's ``error_description`` for this refusal."""
|
|
44
|
+
|
|
38
45
|
max_age: float | None
|
|
39
|
-
"""The freshness window
|
|
46
|
+
"""The route's freshness window in seconds, ``None`` without :meth:`fresh`."""
|
|
47
|
+
|
|
48
|
+
acr_values: tuple[str, ...] | None
|
|
49
|
+
"""The levels the route accepts, ``None`` without :meth:`stepped_up`."""
|
|
40
50
|
|
|
41
51
|
def __init__(
|
|
42
52
|
self,
|
|
@@ -44,18 +54,31 @@ class StaleCredentialError(UnauthorizedError):
|
|
|
44
54
|
headers: dict[str, str] | None = None,
|
|
45
55
|
*,
|
|
46
56
|
max_age: float | None = None,
|
|
57
|
+
acr_values: tuple[str, ...] | None = None,
|
|
47
58
|
) -> None:
|
|
48
59
|
"""Initialize the exception.
|
|
49
60
|
|
|
50
61
|
Args:
|
|
51
62
|
detail: Human-readable message returned in the response body.
|
|
52
63
|
headers: Extra response headers (e.g. the step-up challenge).
|
|
53
|
-
max_age: The freshness window
|
|
64
|
+
max_age: The route's freshness window, in seconds.
|
|
65
|
+
acr_values: The authentication context classes the route accepts.
|
|
54
66
|
"""
|
|
55
67
|
self.max_age = max_age
|
|
68
|
+
self.acr_values = acr_values
|
|
56
69
|
super().__init__(detail=detail, headers=headers)
|
|
57
70
|
|
|
58
71
|
|
|
72
|
+
class StaleCredentialError(InsufficientAuthenticationError):
|
|
73
|
+
"""HTTP 401 — the credential is valid but was proven too long ago.
|
|
74
|
+
|
|
75
|
+
Raised by a route guarded with :meth:`~fastapi_multiauth.AuthSource.fresh`.
|
|
76
|
+
Everything else is as :class:`InsufficientAuthenticationError`.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
error_description: ClassVar[str] = "More recent authentication is required"
|
|
80
|
+
|
|
81
|
+
|
|
59
82
|
class ForbiddenError(HTTPException):
|
|
60
83
|
"""HTTP 403 — the identity is authenticated but lacks permission.
|
|
61
84
|
|
|
@@ -89,9 +112,26 @@ class OAuthDiscoveryError(OAuthError):
|
|
|
89
112
|
"""Raised when the OIDC discovery document cannot be fetched or is invalid."""
|
|
90
113
|
|
|
91
114
|
|
|
115
|
+
class OAuthStateError(OAuthError):
|
|
116
|
+
"""Raised when the OAuth ``state`` parameter fails its CSRF check.
|
|
117
|
+
|
|
118
|
+
The callback did not originate from a redirect this session started: the
|
|
119
|
+
route must not exchange the code.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
|
|
92
123
|
class OAuthExchangeError(OAuthError):
|
|
93
124
|
"""Raised when the OAuth authorization code exchange fails."""
|
|
94
125
|
|
|
95
126
|
|
|
96
127
|
class OAuthUserinfoError(OAuthError):
|
|
97
128
|
"""Raised when the userinfo endpoint cannot be reached or returns garbage."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class OAuthIDTokenError(OAuthError):
|
|
132
|
+
"""Raised when an OpenID Connect ID token fails validation.
|
|
133
|
+
|
|
134
|
+
Its signature, issuer, audience, lifetime, ``nonce`` or ``at_hash`` did not
|
|
135
|
+
check out, or the provider's signing keys could not be fetched: do not
|
|
136
|
+
trust any of its claims.
|
|
137
|
+
"""
|