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.
Files changed (28) hide show
  1. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/PKG-INFO +5 -4
  2. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/README.md +4 -3
  3. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/pyproject.toml +1 -1
  4. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/pyproject.toml.orig +1 -1
  5. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/__init__.py +22 -3
  6. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/abc.py +168 -24
  7. fastapi_multiauth-0.8.0/src/fastapi_multiauth/amr.py +54 -0
  8. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/exceptions.py +49 -9
  9. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/jwt.py +240 -115
  10. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/oauth/__init__.py +10 -1
  11. fastapi_multiauth-0.8.0/src/fastapi_multiauth/oauth/id_token.py +143 -0
  12. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/oauth/utils.py +74 -34
  13. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/basic.py +3 -3
  14. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/cookie.py +30 -3
  15. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/multi.py +47 -7
  16. fastapi_multiauth-0.8.0/src/fastapi_multiauth/utils.py +259 -0
  17. fastapi_multiauth-0.7.0/src/fastapi_multiauth/utils.py +0 -120
  18. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/LICENSE +0 -0
  19. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/_http.py +0 -0
  20. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/_imports.py +0 -0
  21. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/oauth/client.py +0 -0
  22. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/py.typed +0 -0
  23. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/__init__.py +0 -0
  24. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/bearer.py +0 -0
  25. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/header.py +0 -0
  26. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/oauth2.py +0 -0
  27. {fastapi_multiauth-0.7.0 → fastapi_multiauth-0.8.0}/src/fastapi_multiauth/sources/query.py +0 -0
  28. {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.7.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
- - **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).
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
- - **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).
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.7.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.7.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 .exceptions import ForbiddenError, StaleCredentialError, UnauthorizedError
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 hash_token, verify_token_hash
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
- __version__ = "0.7.0"
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 StaleCredentialError, UnauthorizedError
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 _fresh_optional_error(owner: object) -> ValueError:
110
- """Error for a dependency asking for both anonymous callers and freshness."""
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 fresh(): an "
113
- "anonymous caller can never be fresh"
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
- await self._enforce_freshness(credential, identity)
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: anonymous is never fresh.
363
+ ValueError: On a :meth:`fresh` or :meth:`stepped_up` copy:
364
+ anonymous has proven no factor.
290
365
  """
291
- if self._freshness is not None:
292
- raise _fresh_optional_error(self)
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 _fresh_optional_error(self)
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
- 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
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
- if age is None or not (
497
+ return age is not None and (
369
498
  -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
- )
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 StaleCredentialError(UnauthorizedError):
29
- """HTTP 401 — the credential is valid but was proven too long ago.
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.fresh`.
32
- ``detail`` is the stable string ``"Insufficient user authentication"``, and
33
- sources with an HTTP auth scheme carry the
34
- `RFC 9470 <https://datatracker.ietf.org/doc/html/rfc9470>`_ step-up
35
- challenge. A handler rendering its own body reads :attr:`max_age`.
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 the credential missed, in seconds."""
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 that was missed, in seconds.
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
+ """