fastapi-multiauth 0.4.0__tar.gz → 0.5.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 (24) hide show
  1. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/PKG-INFO +3 -2
  2. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/README.md +1 -0
  3. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/pyproject.toml +2 -2
  4. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/pyproject.toml.orig +2 -2
  5. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/__init__.py +7 -1
  6. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/abc.py +95 -19
  7. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/jwt.py +5 -0
  8. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/oauth/client.py +19 -6
  9. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/__init__.py +8 -0
  10. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/basic.py +24 -6
  11. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/bearer.py +42 -26
  12. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/cookie.py +15 -3
  13. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/header.py +3 -0
  14. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/multi.py +55 -12
  15. fastapi_multiauth-0.5.0/src/fastapi_multiauth/sources/oauth2.py +129 -0
  16. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/sources/query.py +3 -0
  17. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/LICENSE +0 -0
  18. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/_http.py +0 -0
  19. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/_imports.py +0 -0
  20. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/exceptions.py +0 -0
  21. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/oauth/__init__.py +0 -0
  22. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/oauth/utils.py +0 -0
  23. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/py.typed +0 -0
  24. {fastapi_multiauth-0.4.0 → fastapi_multiauth-0.5.0}/src/fastapi_multiauth/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-multiauth
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -25,7 +25,7 @@ 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.100.0
28
+ Requires-Dist: fastapi>=0.108.0
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'
@@ -122,6 +122,7 @@ async def me(user=Security(auth)):
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.
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.
125
126
  - **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
126
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.
127
128
  - **Security scopes**: `Security(auth, scopes=[...])` forwards the declared scopes to validators that accept a `scopes` parameter.
@@ -81,6 +81,7 @@ async def me(user=Security(auth)):
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.
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.
84
85
  - **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
85
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.
86
87
  - **Security scopes**: `Security(auth, scopes=[...])` forwards the declared scopes to validators that accept a `scopes` parameter.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-multiauth"
3
- version = "0.4.0"
3
+ version = "0.5.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"
@@ -36,7 +36,7 @@ classifiers = [
36
36
  dependencies = [
37
37
  "anyio>=3.7",
38
38
  "async-lru>=2.0",
39
- "fastapi>=0.100.0",
39
+ "fastapi>=0.108.0",
40
40
  "httpx>=0.25.0",
41
41
  "itsdangerous>=2.1",
42
42
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-multiauth"
3
- version = "0.4.0"
3
+ version = "0.5.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"
@@ -31,7 +31,7 @@ classifiers = [
31
31
  dependencies = [
32
32
  "anyio>=3.7",
33
33
  "async-lru>=2.0",
34
- "fastapi>=0.100.0",
34
+ "fastapi>=0.108.0",
35
35
  "httpx>=0.25.0",
36
36
  "itsdangerous>=2.1",
37
37
  ]
@@ -20,10 +20,13 @@ from .sources import (
20
20
  HTTPBasicAuth,
21
21
  HTTPBearerAuth,
22
22
  MultiAuth,
23
+ OAuth2AuthorizationCodeBearerAuth,
24
+ OAuth2PasswordBearerAuth,
25
+ OpenIdConnectAuth,
23
26
  )
24
27
  from .utils import hash_token, verify_token_hash
25
28
 
26
- __version__ = "0.4.0"
29
+ __version__ = "0.5.0"
27
30
 
28
31
  __all__ = [
29
32
  "APIKeyCookieAuth",
@@ -35,6 +38,9 @@ __all__ = [
35
38
  "HTTPBearerAuth",
36
39
  "JWTValidator",
37
40
  "MultiAuth",
41
+ "OAuth2AuthorizationCodeBearerAuth",
42
+ "OAuth2PasswordBearerAuth",
43
+ "OpenIdConnectAuth",
38
44
  "UnauthorizedError",
39
45
  "hash_token",
40
46
  "oauth",
@@ -4,9 +4,9 @@ import copy
4
4
  import inspect
5
5
  from abc import ABC, abstractmethod
6
6
  from collections.abc import Callable
7
- from typing import TYPE_CHECKING, Annotated, Any
7
+ from typing import TYPE_CHECKING, Any
8
8
 
9
- from fastapi import Depends, HTTPException, Request
9
+ from fastapi import HTTPException, Request
10
10
  from fastapi.security import SecurityScopes
11
11
  from fastapi.security.base import SecurityBase
12
12
 
@@ -46,14 +46,45 @@ def _accepts_kwarg(fn: Callable[..., Any], name: str) -> bool:
46
46
  )
47
47
 
48
48
 
49
- def _unenforceable_scopes_error(owner: object, scopes: list[str]) -> RuntimeError:
50
- """Error for scopes declared on a route that this source cannot check."""
49
+ def _unenforceable_scopes_error(
50
+ owner: object, scopes: list[str], reason: str | None = None
51
+ ) -> RuntimeError:
52
+ """Error for scopes declared on a route that this source cannot check.
53
+
54
+ Args:
55
+ owner: The source (or combination) refusing the route.
56
+ scopes: The scopes the route declared.
57
+ reason: Why they cannot be checked, when it is not simply that this
58
+ source's own validator does not accept them.
59
+ """
60
+ why = reason or "its validator does not declare a 'scopes' parameter"
51
61
  return RuntimeError(
52
62
  f"{type(owner).__name__} cannot enforce the security scopes "
53
- f"{scopes!r} declared on this route: its validator does not "
54
- "declare a 'scopes' parameter. Add one to the validator (or "
55
- "override authenticate_scoped()), or remove scopes=... from "
56
- "Security()."
63
+ f"{scopes!r} declared on this route: {why}. "
64
+ "Add one to the validator (or override authenticate_scoped()), "
65
+ "or remove scopes=... from Security()."
66
+ )
67
+
68
+
69
+ def _anonymous_scopes_error(owner: object, scopes: list[str]) -> RuntimeError:
70
+ """Error for scopes declared on a route that also allows anonymous callers."""
71
+ return RuntimeError(
72
+ f"{type(owner).__name__} is optional() but this route declares the "
73
+ f"security scopes {scopes!r}: an anonymous caller can never satisfy "
74
+ "them, so the route would be gated or not depending on whether a "
75
+ "credential was sent. Drop optional() or drop scopes=... from Security()."
76
+ )
77
+
78
+
79
+ def _undeclared_scopes_error(
80
+ owner: object, unknown: list[str], catalogue: dict[str, str]
81
+ ) -> RuntimeError:
82
+ """Error for route scopes missing from the source's published catalogue."""
83
+ known = ", ".join(sorted(catalogue)) or "nothing"
84
+ return RuntimeError(
85
+ f"{type(owner).__name__} does not publish the security scopes "
86
+ f"{unknown!r} declared on this route: its catalogue lists {known}. "
87
+ "Add them to scopes={...} on the source, or correct the route."
57
88
  )
58
89
 
59
90
 
@@ -72,6 +103,24 @@ class _DocOnlyScheme(SecurityBase):
72
103
  return None
73
104
 
74
105
 
106
+ _SCHEME_CLASSES: dict[type, type] = {}
107
+
108
+
109
+ def _carry_scheme(dependency: Any, scheme: SecurityBase) -> None:
110
+ """Make *dependency* itself the security scheme FastAPI puts in OpenAPI."""
111
+ cls = type(dependency)
112
+ promoted = _SCHEME_CLASSES.get(cls)
113
+ if promoted is None:
114
+ promoted = _SCHEME_CLASSES[cls] = type(
115
+ cls.__name__,
116
+ (cls, SecurityBase),
117
+ {"__module__": cls.__module__, "__doc__": cls.__doc__},
118
+ )
119
+ dependency.__class__ = promoted
120
+ dependency.model = scheme.model
121
+ dependency.scheme_name = scheme.scheme_name
122
+
123
+
75
124
  class AuthSource(ABC):
76
125
  """Abstract base class for authentication sources.
77
126
 
@@ -81,6 +130,9 @@ class AuthSource(ABC):
81
130
  """
82
131
 
83
132
  scheme: SecurityBase | None
133
+ _optional: bool = False
134
+ _scope_catalogue: dict[str, str] | None = None
135
+ """The scopes this source publishes to OpenAPI, ``None`` when it has none."""
84
136
 
85
137
  def __init__(self, scheme: Any = None) -> None:
86
138
  """Set up the FastAPI dependency signature.
@@ -90,6 +142,8 @@ class AuthSource(ABC):
90
142
  metadata is used, extraction always goes through :meth:`extract`.
91
143
  """
92
144
  self.scheme = _DocOnlyScheme(scheme) if scheme is not None else None
145
+ if self.scheme is not None:
146
+ _carry_scheme(self, self.scheme)
93
147
 
94
148
  parameters = [
95
149
  inspect.Parameter(
@@ -103,17 +157,6 @@ class AuthSource(ABC):
103
157
  annotation=SecurityScopes,
104
158
  ),
105
159
  ]
106
- if self.scheme is not None:
107
- # Declared only so the scheme is registered in OpenAPI; the
108
- # extracted value is ignored in favor of extract().
109
- parameters.append(
110
- inspect.Parameter(
111
- "credentials",
112
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
113
- annotation=Annotated[Any, Depends(self.scheme)],
114
- default=None,
115
- )
116
- )
117
160
  self.__signature__ = inspect.Signature(parameters, return_annotation=Any)
118
161
 
119
162
  @abstractmethod
@@ -141,6 +184,10 @@ class AuthSource(ABC):
141
184
  raise _unenforceable_scopes_error(self, scopes)
142
185
  return await self.authenticate(credential)
143
186
 
187
+ def _enforces_scopes(self) -> bool:
188
+ """Whether route-declared scopes reach an actual check in this source."""
189
+ return type(self).authenticate_scoped is not AuthSource.authenticate_scoped
190
+
144
191
  async def _authenticate_with_challenge(
145
192
  self, credential: str, scopes: list[str]
146
193
  ) -> Any:
@@ -151,15 +198,40 @@ class AuthSource(ABC):
151
198
  add_challenge(exc, self.www_authenticate())
152
199
  raise
153
200
 
201
+ def optional(self) -> "Self":
202
+ """Return a copy that yields ``None`` instead of 401 when no credential is sent."""
203
+ clone = copy.copy(self)
204
+ clone._optional = True
205
+ return clone
206
+
207
+ def _reject_undeclared_scopes(self, scopes: list[str]) -> None:
208
+ """Refuse a route declaring scopes this source never published."""
209
+ catalogue = self._scope_catalogue
210
+ if not catalogue:
211
+ return
212
+ unknown = [scope for scope in scopes if scope not in catalogue]
213
+ if unknown:
214
+ raise _undeclared_scopes_error(self, unknown, catalogue)
215
+
154
216
  async def dispatch(self, request: Request, scopes: list[str]) -> Any:
155
217
  """Extract the credential, then authenticate it with the route scopes.
156
218
 
219
+ Returns ``None`` for an absent credential when :meth:`optional` was used.
220
+
157
221
  Raises:
158
222
  UnauthorizedError: When no credential is present. This source's
159
223
  ``WWW-Authenticate`` challenge is attached to any 401 raised.
224
+ RuntimeError: When an :meth:`optional` copy meets a route that
225
+ declares scopes, which an anonymous caller could never satisfy.
160
226
  """
227
+ if scopes:
228
+ if self._optional:
229
+ raise _anonymous_scopes_error(self, scopes)
230
+ self._reject_undeclared_scopes(scopes)
161
231
  credential = await self.extract(request)
162
232
  if credential is None:
233
+ if self._optional:
234
+ return None
163
235
  raise UnauthorizedError(headers=challenge_headers(self.www_authenticate()))
164
236
  return await self._authenticate_with_challenge(credential, scopes)
165
237
 
@@ -215,6 +287,10 @@ class ValidatedAuthSource(AuthSource):
215
287
  """Validate a credential, forwarding route-declared scopes to the validator."""
216
288
  return await self._call_validator(credential, scopes=scopes)
217
289
 
290
+ def _enforces_scopes(self) -> bool:
291
+ """The validator is the authority: every source funnels into it."""
292
+ return self._accepts_scopes
293
+
218
294
  def require(self, **kwargs: Any) -> "Self":
219
295
  """Return a copy of this source with additional (or overriding) validator kwargs.
220
296
 
@@ -256,5 +256,10 @@ class JWTValidator:
256
256
  # provider's JWKS must not take down every login.
257
257
  continue
258
258
  keys[entry.get("kid") or ""] = key
259
+ if not keys:
260
+ raise _JWKSFetchError(
261
+ f"JWKS document has no usable signing key "
262
+ f"({len(document['keys'])} entries)"
263
+ )
259
264
  self._keys = keys
260
265
  self._fetched_at = time.monotonic()
@@ -3,6 +3,7 @@
3
3
  import contextlib
4
4
  import functools
5
5
  from typing import Any, Literal, NamedTuple
6
+ from urllib.parse import urlsplit
6
7
 
7
8
  import httpx
8
9
  from async_lru import alru_cache
@@ -41,13 +42,25 @@ class OIDCEndpoints(NamedTuple):
41
42
 
42
43
  def _validate_issuer(discovery_url: str, issuer: Any) -> None:
43
44
  """Check the discovery document claims the issuer it was fetched from."""
44
- if not discovery_url.endswith(_DISCOVERY_SUFFIX):
45
- return
46
- expected = discovery_url.removesuffix(_DISCOVERY_SUFFIX).rstrip("/")
47
- if not isinstance(issuer, str) or issuer.rstrip("/") != expected:
45
+ if not isinstance(issuer, str) or not issuer:
48
46
  raise OAuthDiscoveryError(
49
- f"discovery document issuer {issuer!r} does not match the "
50
- f"expected issuer {expected!r} derived from the discovery URL"
47
+ f"discovery document has no usable 'issuer' (got {issuer!r})"
48
+ )
49
+ claimed = issuer.rstrip("/")
50
+ parts = urlsplit(discovery_url)
51
+ origin = f"{parts.scheme}://{parts.netloc}"
52
+ path = parts.path.rstrip("/")
53
+ if path.endswith(_DISCOVERY_SUFFIX):
54
+ expected = (origin + path.removesuffix(_DISCOVERY_SUFFIX)).rstrip("/")
55
+ if claimed != expected:
56
+ raise OAuthDiscoveryError(
57
+ f"discovery document issuer {issuer!r} does not match the "
58
+ f"expected issuer {expected!r} derived from the discovery URL"
59
+ )
60
+ elif claimed != origin and not claimed.startswith(f"{origin}/"):
61
+ raise OAuthDiscoveryError(
62
+ f"discovery document issuer {issuer!r} is not served by "
63
+ f"{origin!r}, the origin the document was fetched from"
51
64
  )
52
65
 
53
66
 
@@ -5,6 +5,11 @@ from .bearer import HTTPBearerAuth
5
5
  from .cookie import APIKeyCookieAuth
6
6
  from .header import APIKeyHeaderAuth
7
7
  from .multi import MultiAuth
8
+ from .oauth2 import (
9
+ OAuth2AuthorizationCodeBearerAuth,
10
+ OAuth2PasswordBearerAuth,
11
+ OpenIdConnectAuth,
12
+ )
8
13
  from .query import APIKeyQueryAuth
9
14
 
10
15
  __all__ = [
@@ -14,4 +19,7 @@ __all__ = [
14
19
  "HTTPBasicAuth",
15
20
  "HTTPBearerAuth",
16
21
  "MultiAuth",
22
+ "OAuth2AuthorizationCodeBearerAuth",
23
+ "OAuth2PasswordBearerAuth",
24
+ "OpenIdConnectAuth",
17
25
  ]
@@ -13,6 +13,20 @@ from ..abc import ValidatedAuthSource
13
13
  from ..utils import authorization_credential
14
14
 
15
15
 
16
+ def _basic_challenge(realm: str | None) -> str:
17
+ """Build the ``WWW-Authenticate`` challenge, escaping *realm* (RFC 7230 §3.2.6)."""
18
+ if realm is None:
19
+ return "Basic"
20
+ if not (realm.isascii() and realm.isprintable()):
21
+ raise ValueError(
22
+ f"realm must be printable US-ASCII (RFC 7617 §2.1), got {realm!r}: "
23
+ "control characters have no escaped form in a quoted-string, and "
24
+ "non-ASCII cannot be carried in a header value"
25
+ )
26
+ escaped = realm.replace("\\", "\\\\").replace('"', '\\"')
27
+ return f'Basic realm="{escaped}"'
28
+
29
+
16
30
  class HTTPBasicAuth(ValidatedAuthSource):
17
31
  """HTTP Basic authentication source (wraps ``HTTPBasic`` for OpenAPI, RFC 7617).
18
32
 
@@ -23,8 +37,9 @@ class HTTPBasicAuth(ValidatedAuthSource):
23
37
  validator: Sync or async callable receiving ``(username, password)`` and
24
38
  returning the identity; raises
25
39
  :class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
26
- realm: Optional protection-space name for the ``WWW-Authenticate`` challenge.
40
+ realm: Optional protection-space name for the ``WWW-Authenticate``.
27
41
  scheme_name: OpenAPI security scheme name (default ``HTTPBasic``).
42
+ description: Optional prose for the OpenAPI security scheme object.
28
43
  **kwargs: Extra keyword arguments forwarded to the validator on every call.
29
44
  """
30
45
 
@@ -34,20 +49,23 @@ class HTTPBasicAuth(ValidatedAuthSource):
34
49
  *,
35
50
  realm: str | None = None,
36
51
  scheme_name: str | None = None,
52
+ description: str | None = None,
37
53
  **kwargs: Any,
38
54
  ) -> None:
39
- self._realm = realm
55
+ self._challenge = _basic_challenge(realm)
40
56
  super().__init__(
41
57
  validator,
42
- HTTPBasic(auto_error=False, scheme_name=scheme_name),
58
+ HTTPBasic(
59
+ auto_error=False,
60
+ scheme_name=scheme_name,
61
+ description=description,
62
+ ),
43
63
  **kwargs,
44
64
  )
45
65
 
46
66
  def www_authenticate(self) -> str:
47
67
  """``Basic`` challenge, with the realm when configured (RFC 7617 §2)."""
48
- if self._realm is not None:
49
- return f'Basic realm="{self._realm}"'
50
- return "Basic"
68
+ return self._challenge
51
69
 
52
70
  async def extract(self, request: Request) -> str | None:
53
71
  """Return the base64 credentials blob, or ``None`` when absent or empty.
@@ -1,4 +1,4 @@
1
- """Bearer token authentication source."""
1
+ """Bearer token authentication sources."""
2
2
 
3
3
  import secrets
4
4
  from collections.abc import Callable
@@ -13,7 +13,36 @@ from ..abc import ValidatedAuthSource
13
13
  from ..utils import authorization_credential
14
14
 
15
15
 
16
- class HTTPBearerAuth(ValidatedAuthSource):
16
+ class _BearerSource(ValidatedAuthSource):
17
+ """Extraction shared by every source carrying an ``Authorization: Bearer`` token."""
18
+
19
+ _prefix: str | None = None
20
+
21
+ def www_authenticate(self) -> str:
22
+ """``Bearer`` challenge per RFC 6750 §3."""
23
+ return "Bearer"
24
+
25
+ async def extract(self, request: Request) -> str | None:
26
+ """Return the bearer token, or ``None`` when absent, empty, or mismatched.
27
+
28
+ The ``Bearer`` scheme is matched case-insensitively; the prefix, when
29
+ configured, is kept in the returned value.
30
+ """
31
+ token = authorization_credential(request, "bearer")
32
+ if token is None:
33
+ return None
34
+ if self._prefix is not None and not token.startswith(self._prefix):
35
+ return None
36
+ return token
37
+
38
+ async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
39
+ """Validate the credential, re-checking the prefix and forwarding scopes."""
40
+ if self._prefix is not None and not credential.startswith(self._prefix):
41
+ raise UnauthorizedError()
42
+ return await self._call_validator(credential, scopes=scopes)
43
+
44
+
45
+ class HTTPBearerAuth(_BearerSource):
17
46
  """Bearer token authentication source (wraps ``HTTPBearer`` for OpenAPI).
18
47
 
19
48
  The validator is called as ``await validator(credential, **kwargs)``.
@@ -25,6 +54,9 @@ class HTTPBearerAuth(ValidatedAuthSource):
25
54
  prefix are matched, and the prefix is kept in the validated value.
26
55
  scheme_name: OpenAPI security scheme name (default ``HTTPBearer``); give
27
56
  each source a distinct name when several appear in the same app.
57
+ description: Optional prose for the OpenAPI security scheme object.
58
+ bearer_format: Optional hint at the token format (e.g. ``"JWT"``), for
59
+ documentation only.
28
60
  **kwargs: Extra keyword arguments forwarded to the validator on every call.
29
61
  """
30
62
 
@@ -34,38 +66,22 @@ class HTTPBearerAuth(ValidatedAuthSource):
34
66
  *,
35
67
  prefix: str | None = None,
36
68
  scheme_name: str | None = None,
69
+ description: str | None = None,
70
+ bearer_format: str | None = None,
37
71
  **kwargs: Any,
38
72
  ) -> None:
39
73
  self._prefix = prefix
40
74
  super().__init__(
41
75
  validator,
42
- HTTPBearer(auto_error=False, scheme_name=scheme_name),
76
+ HTTPBearer(
77
+ auto_error=False,
78
+ scheme_name=scheme_name,
79
+ description=description,
80
+ bearerFormat=bearer_format,
81
+ ),
43
82
  **kwargs,
44
83
  )
45
84
 
46
- def www_authenticate(self) -> str:
47
- """``Bearer`` challenge per RFC 6750 §3."""
48
- return "Bearer"
49
-
50
- async def extract(self, request: Request) -> str | None:
51
- """Return the bearer token, or ``None`` when absent, empty, or mismatched.
52
-
53
- The ``Bearer`` scheme is matched case-insensitively; the prefix, when
54
- configured, is kept in the returned value.
55
- """
56
- token = authorization_credential(request, "bearer")
57
- if token is None:
58
- return None
59
- if self._prefix is not None and not token.startswith(self._prefix):
60
- return None
61
- return token
62
-
63
- async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
64
- """Validate the credential, re-checking the prefix and forwarding scopes."""
65
- if self._prefix is not None and not credential.startswith(self._prefix):
66
- raise UnauthorizedError()
67
- return await self._call_validator(credential, scopes=scopes)
68
-
69
85
  def generate_token(self, nbytes: int = 32) -> str:
70
86
  """Generate a secure URL-safe random token, prefixed when configured.
71
87
 
@@ -36,6 +36,16 @@ def _normalize_secret_keys(secret_key: str | Sequence[str] | None) -> list[str]:
36
36
  return keys
37
37
 
38
38
 
39
+ def _precomputed_signer(keys: list[str], salt: str) -> TimestampSigner:
40
+ """A ``TimestampSigner`` deriving its keys once here instead of per ``unsign``."""
41
+ probe = TimestampSigner(keys, salt=salt, digest_method=hashlib.sha256)
42
+ return TimestampSigner(
43
+ [probe.derive_key(key) for key in keys],
44
+ digest_method=hashlib.sha256,
45
+ key_derivation="none",
46
+ )
47
+
48
+
39
49
  class APIKeyCookieAuth(ValidatedAuthSource):
40
50
  """Cookie-based authentication source (wraps ``APIKeyCookie`` for OpenAPI).
41
51
 
@@ -60,6 +70,7 @@ class APIKeyCookieAuth(ValidatedAuthSource):
60
70
  domain: Optional ``Domain`` cookie attribute.
61
71
  path: ``Path`` cookie attribute (default ``"/"``).
62
72
  scheme_name: OpenAPI security scheme name (default ``APIKeyCookie_{name}``).
73
+ description: Optional prose for the OpenAPI security scheme object.
63
74
  **kwargs: Extra keyword arguments forwarded to the validator on every call.
64
75
  """
65
76
 
@@ -76,6 +87,7 @@ class APIKeyCookieAuth(ValidatedAuthSource):
76
87
  domain: str | None = None,
77
88
  path: str = "/",
78
89
  scheme_name: str | None = None,
90
+ description: str | None = None,
79
91
  **kwargs: Any,
80
92
  ) -> None:
81
93
  if ttl <= 0:
@@ -98,10 +110,9 @@ class APIKeyCookieAuth(ValidatedAuthSource):
98
110
  # the last one — reverse so the first configured key is the signer.
99
111
  # The salt also separates the two payload layouts.
100
112
  self._signer = (
101
- TimestampSigner(
113
+ _precomputed_signer(
102
114
  list(reversed(self._secret_keys)),
103
- salt=f"{_SALT_PREFIX}{name}{'.sid' if session_id else ''}",
104
- digest_method=hashlib.sha256,
115
+ f"{_SALT_PREFIX}{name}{'.sid' if session_id else ''}",
105
116
  )
106
117
  if self._secret_keys
107
118
  else None
@@ -117,6 +128,7 @@ class APIKeyCookieAuth(ValidatedAuthSource):
117
128
  name=name,
118
129
  auto_error=False,
119
130
  scheme_name=scheme_name or f"APIKeyCookie_{name}",
131
+ description=description,
120
132
  ),
121
133
  **kwargs,
122
134
  )
@@ -19,6 +19,7 @@ class APIKeyHeaderAuth(ValidatedAuthSource):
19
19
  validator: Sync or async callable returning the identity; raises
20
20
  :class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
21
21
  scheme_name: OpenAPI security scheme name (default ``APIKeyHeader_{name}``).
22
+ description: Optional prose for the OpenAPI security scheme object.
22
23
  **kwargs: Extra keyword arguments forwarded to the validator on every call.
23
24
  """
24
25
 
@@ -28,6 +29,7 @@ class APIKeyHeaderAuth(ValidatedAuthSource):
28
29
  validator: Callable[..., Any],
29
30
  *,
30
31
  scheme_name: str | None = None,
32
+ description: str | None = None,
31
33
  **kwargs: Any,
32
34
  ) -> None:
33
35
  self._name = name
@@ -37,6 +39,7 @@ class APIKeyHeaderAuth(ValidatedAuthSource):
37
39
  name=name,
38
40
  auto_error=False,
39
41
  scheme_name=scheme_name or f"APIKeyHeader_{name}",
42
+ description=description,
40
43
  ),
41
44
  **kwargs,
42
45
  )
@@ -1,14 +1,20 @@
1
1
  """MultiAuth: combine multiple authentication sources into a single callable."""
2
2
 
3
+ import copy
3
4
  import inspect
4
- from typing import Any, cast
5
+ from typing import Annotated, Any, cast
5
6
 
6
- from fastapi import Request
7
+ from fastapi import Depends, Request
7
8
  from fastapi.security import SecurityScopes
8
9
 
9
10
  from fastapi_multiauth.exceptions import UnauthorizedError
10
11
 
11
- from ..abc import AuthSource
12
+ from ..abc import (
13
+ AuthSource,
14
+ _anonymous_scopes_error,
15
+ _carry_scheme,
16
+ _unenforceable_scopes_error,
17
+ )
12
18
  from ..utils import challenge_headers
13
19
 
14
20
 
@@ -25,6 +31,8 @@ class MultiAuth:
25
31
  TypeError: If a source is not an :class:`AuthSource` instance.
26
32
  """
27
33
 
34
+ _optional: bool = False
35
+
28
36
  def __init__(self, *sources: AuthSource) -> None:
29
37
  for source in sources:
30
38
  if not isinstance(source, AuthSource):
@@ -39,10 +47,6 @@ class MultiAuth:
39
47
  )
40
48
  self._sources = sources
41
49
 
42
- # Build a merged signature that includes the security-scheme Depends()
43
- # parameters from every source so FastAPI registers them in OpenAPI
44
- # docs. Parameters are renamed `_s{i}_{name}` — unique per source
45
- # index — so two sources of the same type both keep their scheme.
46
50
  merged: list[inspect.Parameter] = [
47
51
  inspect.Parameter(
48
52
  "request",
@@ -55,11 +59,20 @@ class MultiAuth:
55
59
  annotation=SecurityScopes,
56
60
  ),
57
61
  ]
62
+ carried = sources[0].scheme if sources else None
63
+ if carried is not None:
64
+ _carry_scheme(self, carried)
58
65
  for i, source in enumerate(sources):
59
- for name, param in inspect.signature(source).parameters.items():
60
- if name in ("request", "security_scopes"):
61
- continue
62
- merged.append(param.replace(name=f"_s{i}_{name}"))
66
+ if source.scheme is None or (i == 0 and carried is not None):
67
+ continue
68
+ merged.append(
69
+ inspect.Parameter(
70
+ f"_s{i}_credentials",
71
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
72
+ annotation=Annotated[Any, Depends(cast(Any, source.scheme))],
73
+ default=None,
74
+ )
75
+ )
63
76
  self.__signature__ = inspect.Signature(merged, return_annotation=Any)
64
77
 
65
78
  # The combined challenge is fixed once the sources are known; build it
@@ -71,16 +84,44 @@ class MultiAuth:
71
84
  challenges.append(challenge)
72
85
  self._www_authenticate = ", ".join(challenges) or None
73
86
 
87
+ self._unenforceable = tuple(
88
+ type(source).__name__ for source in sources if not source._enforces_scopes()
89
+ )
90
+ self._catalogued = tuple(
91
+ source for source in sources if source._scope_catalogue
92
+ )
93
+
74
94
  def www_authenticate(self) -> str | None:
75
95
  """Combined challenge of all sources (RFC 9110 §11.6.1), or ``None``."""
76
96
  return self._www_authenticate
77
97
 
98
+ def optional(self) -> "MultiAuth":
99
+ """Return a copy that yields ``None`` when no source has a credential."""
100
+ clone = copy.copy(self)
101
+ clone._optional = True
102
+ return clone
103
+
78
104
  async def dispatch(self, request: Request, scopes: list[str]) -> Any:
79
105
  """Authenticate with the first source whose credential is present."""
106
+ if scopes:
107
+ if self._optional:
108
+ raise _anonymous_scopes_error(self, scopes)
109
+ for source in self._catalogued:
110
+ source._reject_undeclared_scopes(scopes)
111
+ if self._unenforceable:
112
+ raise _unenforceable_scopes_error(
113
+ self,
114
+ scopes,
115
+ f"{', '.join(self._unenforceable)} cannot check them, so "
116
+ "enforcement would depend on which credential the client "
117
+ "presents",
118
+ )
80
119
  for source in self._sources:
81
120
  credential = await source.extract(request)
82
121
  if credential is not None:
83
122
  return await source._authenticate_with_challenge(credential, scopes)
123
+ if self._optional:
124
+ return None
84
125
  raise UnauthorizedError(headers=challenge_headers(self.www_authenticate()))
85
126
 
86
127
  async def __call__(self, **kwargs: Any) -> Any:
@@ -94,4 +135,6 @@ class MultiAuth:
94
135
  else source
95
136
  for source in self._sources
96
137
  )
97
- return MultiAuth(*new_sources)
138
+ clone = MultiAuth(*new_sources)
139
+ clone._optional = self._optional
140
+ return clone
@@ -0,0 +1,129 @@
1
+ """OAuth 2.0 and OpenID Connect authentication sources.
2
+
3
+ These validate the bearer token an OAuth 2.0 or OIDC provider already issued.
4
+ They declare no flow of their own: the URLs and scope catalogue they carry are
5
+ OpenAPI metadata, which is what lets ``/docs`` offer a real Authorize dialog
6
+ with per-scope checkboxes. The login flow that mints those tokens lives in
7
+ :mod:`fastapi_multiauth.oauth`.
8
+ """
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from fastapi.security import (
14
+ OAuth2AuthorizationCodeBearer,
15
+ OAuth2PasswordBearer,
16
+ OpenIdConnect,
17
+ )
18
+
19
+ from .bearer import _BearerSource
20
+
21
+
22
+ class OAuth2PasswordBearerAuth(_BearerSource):
23
+ """Bearer tokens issued by a password-grant token endpoint.
24
+
25
+ Args:
26
+ validator: Sync or async callable returning the identity.
27
+ token_url: URL of the token endpoint, as advertised to OpenAPI.
28
+ scopes: Catalogue of scope names mapped to their descriptions.
29
+ scheme_name: OpenAPI security scheme name.
30
+ description: Optional prose for the OpenAPI security scheme object.
31
+ **kwargs: Extra keyword arguments forwarded to the validator.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ validator: Callable[..., Any],
37
+ *,
38
+ token_url: str,
39
+ scopes: dict[str, str] | None = None,
40
+ scheme_name: str | None = None,
41
+ description: str | None = None,
42
+ **kwargs: Any,
43
+ ) -> None:
44
+ self._scope_catalogue = scopes
45
+ super().__init__(
46
+ validator,
47
+ OAuth2PasswordBearer(
48
+ tokenUrl=token_url,
49
+ scopes=scopes,
50
+ scheme_name=scheme_name,
51
+ description=description,
52
+ auto_error=False,
53
+ ),
54
+ **kwargs,
55
+ )
56
+
57
+
58
+ class OAuth2AuthorizationCodeBearerAuth(_BearerSource):
59
+ """Bearer tokens issued by an authorization-code flow.
60
+
61
+ Args:
62
+ validator: Sync or async callable returning the identity.
63
+ authorization_url: URL the Authorize dialog sends the user to.
64
+ token_url: URL that exchanges the code for a token.
65
+ refresh_url: Optional refresh endpoint.
66
+ scopes: Catalogue of scope names mapped to their descriptions.
67
+ scheme_name: OpenAPI security scheme name.
68
+ description: Optional prose for the OpenAPI security scheme object.
69
+ **kwargs: Extra keyword arguments forwarded to the validator.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ validator: Callable[..., Any],
75
+ *,
76
+ authorization_url: str,
77
+ token_url: str,
78
+ refresh_url: str | None = None,
79
+ scopes: dict[str, str] | None = None,
80
+ scheme_name: str | None = None,
81
+ description: str | None = None,
82
+ **kwargs: Any,
83
+ ) -> None:
84
+ self._scope_catalogue = scopes
85
+ super().__init__(
86
+ validator,
87
+ OAuth2AuthorizationCodeBearer(
88
+ authorizationUrl=authorization_url,
89
+ tokenUrl=token_url,
90
+ refreshUrl=refresh_url,
91
+ scopes=scopes,
92
+ scheme_name=scheme_name,
93
+ description=description,
94
+ auto_error=False,
95
+ ),
96
+ **kwargs,
97
+ )
98
+
99
+
100
+ class OpenIdConnectAuth(_BearerSource):
101
+ """Bearer tokens issued by an OpenID Connect provider.
102
+
103
+ Args:
104
+ validator: Sync or async callable returning the identity.
105
+ openid_connect_url: The provider's ``.well-known`` discovery URL.
106
+ scheme_name: OpenAPI security scheme name.
107
+ description: Optional prose for the OpenAPI security scheme object.
108
+ **kwargs: Extra keyword arguments forwarded to the validator.
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ validator: Callable[..., Any],
114
+ *,
115
+ openid_connect_url: str,
116
+ scheme_name: str | None = None,
117
+ description: str | None = None,
118
+ **kwargs: Any,
119
+ ) -> None:
120
+ super().__init__(
121
+ validator,
122
+ OpenIdConnect(
123
+ openIdConnectUrl=openid_connect_url,
124
+ scheme_name=scheme_name,
125
+ description=description,
126
+ auto_error=False,
127
+ ),
128
+ **kwargs,
129
+ )
@@ -24,6 +24,7 @@ class APIKeyQueryAuth(ValidatedAuthSource):
24
24
  validator: Sync or async callable returning the identity; raises
25
25
  :class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
26
26
  scheme_name: OpenAPI security scheme name (default ``APIKeyQuery_{name}``).
27
+ description: Optional prose for the OpenAPI security scheme object.
27
28
  **kwargs: Extra keyword arguments forwarded to the validator on every call.
28
29
  """
29
30
 
@@ -33,6 +34,7 @@ class APIKeyQueryAuth(ValidatedAuthSource):
33
34
  validator: Callable[..., Any],
34
35
  *,
35
36
  scheme_name: str | None = None,
37
+ description: str | None = None,
36
38
  **kwargs: Any,
37
39
  ) -> None:
38
40
  self._name = name
@@ -42,6 +44,7 @@ class APIKeyQueryAuth(ValidatedAuthSource):
42
44
  name=name,
43
45
  auto_error=False,
44
46
  scheme_name=scheme_name or f"APIKeyQuery_{name}",
47
+ description=description,
45
48
  ),
46
49
  **kwargs,
47
50
  )