fastapi-multiauth 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,153 @@
1
+ """Cookie-based authentication source."""
2
+
3
+ import hashlib
4
+ from collections.abc import Sequence
5
+ from typing import Any, Callable, Literal
6
+
7
+ from fastapi import Request, Response
8
+ from fastapi.security import APIKeyCookie
9
+ from itsdangerous import BadSignature, TimestampSigner
10
+
11
+ from fastapi_multiauth.exceptions import UnauthorizedError
12
+
13
+ from ..abc import ValidatedAuthSource
14
+
15
+ _MIN_SECRET_KEY_BYTES = 32
16
+ _SALT_PREFIX = "fastapi-multiauth.cookie."
17
+
18
+ SameSite = Literal["lax", "strict", "none"]
19
+
20
+
21
+ def _normalize_secret_keys(secret_key: str | Sequence[str] | None) -> list[str]:
22
+ """Validate and normalize ``secret_key`` into a list of keys."""
23
+ if secret_key is None:
24
+ return []
25
+ keys = [secret_key] if isinstance(secret_key, str) else list(secret_key)
26
+ if not keys:
27
+ raise ValueError("secret_key must contain at least one key")
28
+ for key in keys:
29
+ if len(key.encode()) < _MIN_SECRET_KEY_BYTES:
30
+ raise ValueError(
31
+ f"each secret_key must be at least {_MIN_SECRET_KEY_BYTES} "
32
+ "bytes of high-entropy material (e.g. secrets.token_urlsafe(32)); "
33
+ "an empty or short key would weaken cookie signing"
34
+ )
35
+ return keys
36
+
37
+
38
+ class APIKeyCookieAuth(ValidatedAuthSource):
39
+ """Cookie-based authentication source (wraps ``APIKeyCookie`` for OpenAPI).
40
+
41
+ When ``secret_key`` is set, the cookie is signed (HMAC-SHA256 with an
42
+ embedded timestamp, salted by the cookie name) for stateless, tamper-proof
43
+ sessions; otherwise the raw cookie value is passed through.
44
+
45
+ Args:
46
+ name: Cookie name.
47
+ validator: Sync or async callable receiving the cookie value (plain,
48
+ after signature verification when signed) and returning the identity.
49
+ secret_key: One key or a sequence to rotate (first signs, all verify);
50
+ each at least 32 bytes. When ``None``, the cookie is not signed.
51
+ ttl: Cookie lifetime in seconds (default 24 h); also enforced
52
+ server-side when the cookie is signed.
53
+ secure: Set the ``Secure`` flag (default ``True``); disable only in local dev.
54
+ samesite: ``SameSite`` cookie attribute (default ``"lax"``).
55
+ domain: Optional ``Domain`` cookie attribute.
56
+ path: ``Path`` cookie attribute (default ``"/"``).
57
+ scheme_name: OpenAPI security scheme name (default ``APIKeyCookie_{name}``).
58
+ **kwargs: Extra keyword arguments forwarded to the validator on every call.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ name: str,
64
+ validator: Callable[..., Any],
65
+ *,
66
+ secret_key: str | Sequence[str] | None = None,
67
+ ttl: int = 86400,
68
+ secure: bool = True,
69
+ samesite: SameSite = "lax",
70
+ domain: str | None = None,
71
+ path: str = "/",
72
+ scheme_name: str | None = None,
73
+ **kwargs: Any,
74
+ ) -> None:
75
+ if ttl <= 0:
76
+ raise ValueError("ttl must be a positive number of seconds")
77
+ self._name = name
78
+ self._secret_keys = _normalize_secret_keys(secret_key)
79
+ # itsdangerous verifies against every key in the list but signs with
80
+ # the last one — reverse so the first configured key is the signer.
81
+ self._signer = (
82
+ TimestampSigner(
83
+ list(reversed(self._secret_keys)),
84
+ salt=f"{_SALT_PREFIX}{name}",
85
+ digest_method=hashlib.sha256,
86
+ )
87
+ if self._secret_keys
88
+ else None
89
+ )
90
+ self._ttl = ttl
91
+ self._secure = secure
92
+ self._samesite: SameSite = samesite
93
+ self._domain = domain
94
+ self._path = path
95
+ super().__init__(
96
+ validator,
97
+ APIKeyCookie(
98
+ name=name,
99
+ auto_error=False,
100
+ scheme_name=scheme_name or f"APIKeyCookie_{name}",
101
+ ),
102
+ **kwargs,
103
+ )
104
+
105
+ def _sign(self, value: str) -> str:
106
+ if self._signer is None:
107
+ raise RuntimeError("_sign called without secret_key configured")
108
+ return self._signer.sign(value.encode()).decode()
109
+
110
+ def _verify(self, cookie_value: str) -> str:
111
+ """Return the plain value, verifying signature + expiry when signed."""
112
+ if self._signer is None:
113
+ return cookie_value
114
+ try:
115
+ return self._signer.unsign(
116
+ cookie_value.encode(), max_age=self._ttl
117
+ ).decode()
118
+ except BadSignature:
119
+ raise UnauthorizedError() from None
120
+
121
+ async def extract(self, request: Request) -> str | None:
122
+ """Return the raw cookie value, or ``None`` when absent or empty."""
123
+ return request.cookies.get(self._name) or None
124
+
125
+ async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
126
+ """Verify the cookie, forwarding route-declared scopes to the validator."""
127
+ plain = self._verify(credential)
128
+ return await self._call_validator(plain, scopes=scopes)
129
+
130
+ def set_cookie(self, response: Response, value: str) -> None:
131
+ """Attach the cookie to *response*, signing it when ``secret_key`` is set."""
132
+ cookie_value = self._sign(value) if self._signer else value
133
+ response.set_cookie(
134
+ self._name,
135
+ cookie_value,
136
+ httponly=True,
137
+ samesite=self._samesite,
138
+ secure=self._secure,
139
+ max_age=self._ttl,
140
+ domain=self._domain,
141
+ path=self._path,
142
+ )
143
+
144
+ def delete_cookie(self, response: Response) -> None:
145
+ """Clear the session cookie (logout) from this client only."""
146
+ response.delete_cookie(
147
+ self._name,
148
+ httponly=True,
149
+ samesite=self._samesite,
150
+ secure=self._secure,
151
+ domain=self._domain,
152
+ path=self._path,
153
+ )
@@ -0,0 +1,45 @@
1
+ """API key header authentication source."""
2
+
3
+ from typing import Any, Callable
4
+
5
+ from fastapi import Request
6
+ from fastapi.security import APIKeyHeader
7
+
8
+ from ..abc import ValidatedAuthSource
9
+
10
+
11
+ class APIKeyHeaderAuth(ValidatedAuthSource):
12
+ """API key header authentication source (wraps ``APIKeyHeader`` for OpenAPI).
13
+
14
+ The validator is called as ``await validator(api_key, **kwargs)``.
15
+
16
+ Args:
17
+ name: HTTP header name that carries the API key (e.g. ``"X-API-Key"``).
18
+ validator: Sync or async callable returning the identity; raises
19
+ :class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
20
+ scheme_name: OpenAPI security scheme name (default ``APIKeyHeader_{name}``).
21
+ **kwargs: Extra keyword arguments forwarded to the validator on every call.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ name: str,
27
+ validator: Callable[..., Any],
28
+ *,
29
+ scheme_name: str | None = None,
30
+ **kwargs: Any,
31
+ ) -> None:
32
+ self._name = name
33
+ super().__init__(
34
+ validator,
35
+ APIKeyHeader(
36
+ name=name,
37
+ auto_error=False,
38
+ scheme_name=scheme_name or f"APIKeyHeader_{name}",
39
+ ),
40
+ **kwargs,
41
+ )
42
+
43
+ async def extract(self, request: Request) -> str | None:
44
+ """Return the API key from the configured header, or ``None`` when absent or empty."""
45
+ return request.headers.get(self._name) or None
@@ -0,0 +1,97 @@
1
+ """MultiAuth: combine multiple authentication sources into a single callable."""
2
+
3
+ import inspect
4
+ from typing import Any, cast
5
+
6
+ from fastapi import Request
7
+ from fastapi.security import SecurityScopes
8
+
9
+ from fastapi_multiauth.exceptions import UnauthorizedError
10
+
11
+ from ..abc import AuthSource
12
+ from ..utils import challenge_headers
13
+
14
+
15
+ class MultiAuth:
16
+ """Combine multiple authentication sources into a single callable.
17
+
18
+ Sources are tried in declaration order through the same ``extract()``/
19
+ ``authenticate()`` pair used by direct ``Security(source)`` access.
20
+
21
+ Args:
22
+ *sources: Auth source instances to try in order.
23
+
24
+ Raises:
25
+ TypeError: If a source is not an :class:`AuthSource` instance.
26
+ """
27
+
28
+ def __init__(self, *sources: AuthSource) -> None:
29
+ for source in sources:
30
+ if not isinstance(source, AuthSource):
31
+ hint = (
32
+ " (MultiAuth cannot be nested)"
33
+ if isinstance(source, MultiAuth)
34
+ else ""
35
+ )
36
+ raise TypeError(
37
+ "MultiAuth sources must be AuthSource instances, "
38
+ f"got {type(source).__name__}{hint}"
39
+ )
40
+ self._sources = sources
41
+
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
+ merged: list[inspect.Parameter] = [
47
+ inspect.Parameter(
48
+ "request",
49
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
50
+ annotation=Request,
51
+ ),
52
+ inspect.Parameter(
53
+ "security_scopes",
54
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
55
+ annotation=SecurityScopes,
56
+ ),
57
+ ]
58
+ 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}"))
63
+ self.__signature__ = inspect.Signature(merged, return_annotation=Any)
64
+
65
+ # The combined challenge is fixed once the sources are known; build it
66
+ # once here instead of rebuilding it on every unauthenticated request.
67
+ challenges: list[str] = []
68
+ for source in sources:
69
+ challenge = source.www_authenticate()
70
+ if challenge and challenge not in challenges:
71
+ challenges.append(challenge)
72
+ self._www_authenticate = ", ".join(challenges) or None
73
+
74
+ def www_authenticate(self) -> str | None:
75
+ """Combined challenge of all sources (RFC 9110 §11.6.1), or ``None``."""
76
+ return self._www_authenticate
77
+
78
+ async def dispatch(self, request: Request, scopes: list[str]) -> Any:
79
+ """Authenticate with the first source whose credential is present."""
80
+ for source in self._sources:
81
+ credential = await source.extract(request)
82
+ if credential is not None:
83
+ return await source._authenticate_with_challenge(credential, scopes)
84
+ raise UnauthorizedError(headers=challenge_headers(self.www_authenticate()))
85
+
86
+ async def __call__(self, **kwargs: Any) -> Any:
87
+ return await self.dispatch(kwargs["request"], kwargs["security_scopes"].scopes)
88
+
89
+ def require(self, **kwargs: Any) -> "MultiAuth":
90
+ """Return a new :class:`MultiAuth` with kwargs forwarded to each source."""
91
+ new_sources = tuple(
92
+ cast(Any, source).require(**kwargs)
93
+ if hasattr(source, "require")
94
+ else source
95
+ for source in self._sources
96
+ )
97
+ return MultiAuth(*new_sources)
@@ -0,0 +1,50 @@
1
+ """API key query-string authentication source."""
2
+
3
+ from typing import Any, Callable
4
+
5
+ from fastapi import Request
6
+ from fastapi.security import APIKeyQuery
7
+
8
+ from ..abc import ValidatedAuthSource
9
+
10
+
11
+ class APIKeyQueryAuth(ValidatedAuthSource):
12
+ """API key query-string authentication source (wraps ``APIKeyQuery`` for OpenAPI).
13
+
14
+ The validator is called as ``await validator(api_key, **kwargs)``.
15
+
16
+ .. warning::
17
+ Credentials in the query string leak into access logs, browser history,
18
+ and ``Referer`` headers. Prefer a header or cookie when you can; use this
19
+ only for clients that cannot set headers.
20
+
21
+ Args:
22
+ name: Query parameter name that carries the API key (e.g. ``"api_key"``).
23
+ validator: Sync or async callable returning the identity; raises
24
+ :class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
25
+ scheme_name: OpenAPI security scheme name (default ``APIKeyQuery_{name}``).
26
+ **kwargs: Extra keyword arguments forwarded to the validator on every call.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ name: str,
32
+ validator: Callable[..., Any],
33
+ *,
34
+ scheme_name: str | None = None,
35
+ **kwargs: Any,
36
+ ) -> None:
37
+ self._name = name
38
+ super().__init__(
39
+ validator,
40
+ APIKeyQuery(
41
+ name=name,
42
+ auto_error=False,
43
+ scheme_name=scheme_name or f"APIKeyQuery_{name}",
44
+ ),
45
+ **kwargs,
46
+ )
47
+
48
+ async def extract(self, request: Request) -> str | None:
49
+ """Return the API key from the configured query parameter, or ``None`` when absent or empty."""
50
+ return request.query_params.get(self._name) or None
@@ -0,0 +1,83 @@
1
+ """Standalone helpers: public token utilities plus internal source helpers."""
2
+
3
+ import functools
4
+ import hashlib
5
+ import hmac
6
+ import inspect
7
+ from typing import Any, Callable
8
+
9
+ from fastapi import HTTPException, Request
10
+
11
+
12
+ def hash_token(token: str) -> str:
13
+ """Return the SHA-256 hex digest of an opaque token.
14
+
15
+ Args:
16
+ token: The opaque token, including any prefix.
17
+
18
+ Returns:
19
+ A 64-character lowercase hex digest.
20
+ """
21
+ return hashlib.sha256(token.encode()).hexdigest()
22
+
23
+
24
+ def verify_token_hash(token: str, stored_hash: str) -> bool:
25
+ """Compare a presented token against a stored hash in constant time.
26
+
27
+ Args:
28
+ token: The opaque token presented by the client.
29
+ stored_hash: The hex digest previously stored via :func:`hash_token`.
30
+
31
+ Returns:
32
+ ``True`` if the token matches the stored hash.
33
+ """
34
+ return hmac.compare_digest(hash_token(token), stored_hash)
35
+
36
+
37
+ def ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
38
+ """Wrap *fn* so it can always be awaited, regardless of sync or async."""
39
+ if inspect.iscoroutinefunction(fn) or inspect.iscoroutinefunction(
40
+ getattr(fn, "__call__", None) # noqa: B004 — detecting async __call__, not callability
41
+ ):
42
+ return fn
43
+
44
+ @functools.wraps(fn)
45
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
46
+ result = fn(*args, **kwargs)
47
+ if inspect.isawaitable(result):
48
+ return await result
49
+ return result
50
+
51
+ return wrapper
52
+
53
+
54
+ def challenge_headers(challenge: str | None) -> dict[str, str] | None:
55
+ """Build the ``WWW-Authenticate`` header dict for a 401 (RFC 7235 §4.1)."""
56
+ if not challenge:
57
+ return None
58
+ return {"WWW-Authenticate": challenge}
59
+
60
+
61
+ def add_challenge(exc: HTTPException, challenge: str | None) -> None:
62
+ """Attach a ``WWW-Authenticate`` challenge to a 401 that lacks one."""
63
+ if exc.status_code != 401 or not challenge:
64
+ return
65
+ headers = dict(exc.headers or {})
66
+ if "WWW-Authenticate" not in headers:
67
+ headers["WWW-Authenticate"] = challenge
68
+ exc.headers = headers
69
+
70
+
71
+ def authorization_credential(request: Request, scheme: str) -> str | None:
72
+ """Extract the value of an ``Authorization: <scheme> <value>`` header.
73
+
74
+ The scheme is matched case-insensitively (RFC 7235 §2.1). Returns ``None``
75
+ when the header is absent, carries a different scheme, or has an empty value.
76
+ """
77
+ authorization = request.headers.get("Authorization")
78
+ if authorization is None:
79
+ return None
80
+ header_scheme, sep, value = authorization.partition(" ")
81
+ if not sep or header_scheme.lower() != scheme:
82
+ return None
83
+ return value.strip() or None
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-multiauth
3
+ Version: 0.1.0
4
+ Summary: Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes, correct OpenAPI by construction
5
+ Keywords: fastapi,authentication,security,oauth,api-key,bearer,cookie
6
+ Author: d3vyce
7
+ Author-email: d3vyce <contact@d3vyce.fr>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Internet :: WWW/HTTP :: Session
22
+ Classifier: Topic :: Security
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Typing :: Typed
26
+ Requires-Dist: anyio>=3.7
27
+ Requires-Dist: async-lru>=2.0
28
+ Requires-Dist: fastapi>=0.100.0
29
+ Requires-Dist: httpx>=0.25.0
30
+ Requires-Dist: itsdangerous>=2.1
31
+ Requires-Dist: pyjwt[crypto]>=2.8 ; extra == 'jwt'
32
+ Requires-Dist: httpx-oauth>=0.16 ; extra == 'oauth'
33
+ Requires-Python: >=3.10
34
+ Project-URL: Homepage, https://github.com/d3vyce/fastapi-multiauth
35
+ Project-URL: Documentation, https://fastapi-multiauth.d3vyce.fr/
36
+ Project-URL: Repository, https://github.com/d3vyce/fastapi-multiauth
37
+ Project-URL: Issues, https://github.com/d3vyce/fastapi-multiauth/issues
38
+ Provides-Extra: jwt
39
+ Provides-Extra: oauth
40
+ Description-Content-Type: text/markdown
41
+
42
+ # FastAPI MultiAuth
43
+
44
+ Composable authentication sources for FastAPI: bring your own validator, combine multiple schemes with `MultiAuth`, and get correct OpenAPI documentation by construction.
45
+
46
+ [![CI](https://github.com/d3vyce/fastapi-multiauth/actions/workflows/ci.yml/badge.svg)](https://github.com/d3vyce/fastapi-multiauth/actions/workflows/ci.yml)
47
+ [![codecov](https://codecov.io/gh/d3vyce/fastapi-multiauth/graph/badge.svg)](https://codecov.io/gh/d3vyce/fastapi-multiauth)
48
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
49
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
50
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
51
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
52
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
53
+
54
+ ---
55
+
56
+ **Documentation**: [https://fastapi-multiauth.d3vyce.fr](https://fastapi-multiauth.d3vyce.fr)
57
+
58
+ **Source Code**: [https://github.com/d3vyce/fastapi-multiauth](https://github.com/d3vyce/fastapi-multiauth)
59
+
60
+ ---
61
+
62
+ `fastapi-multiauth` is the composable layer between the raw primitives of `fastapi.security` and opinionated frameworks like fastapi-users or AuthX. You provide the validator; the library handles extraction, scheme combination, and OpenAPI declaration. No database coupling, no user model, no ready-made login routes.
63
+
64
+ ## Installation
65
+
66
+ ```bash
67
+ uv add fastapi-multiauth
68
+ ```
69
+
70
+ Optional extras pull in their dependencies on demand:
71
+
72
+ ```bash
73
+ uv add "fastapi-multiauth[jwt]" # JWTValidator (PyJWT)
74
+ uv add "fastapi-multiauth[oauth]" # OAuth 2.0 / OIDC helpers (httpx-oauth)
75
+ uv add "fastapi-multiauth[jwt,oauth]"
76
+ ```
77
+
78
+ ## Quick Start
79
+
80
+ ```python
81
+ from fastapi import FastAPI, Security
82
+ from fastapi_multiauth import HTTPBearerAuth, APIKeyCookieAuth, MultiAuth, UnauthorizedError
83
+
84
+ async def validate_token(token: str) -> dict:
85
+ user = await lookup_user_by_token(token)
86
+ if user is None:
87
+ raise UnauthorizedError()
88
+ return user
89
+
90
+ async def validate_session(value: str) -> dict:
91
+ return await lookup_user_by_session(value)
92
+
93
+ bearer = HTTPBearerAuth(validate_token, prefix="user_")
94
+ session = APIKeyCookieAuth("session", validate_session, secret_key="...")
95
+
96
+ # Accept either an API token or a web session on the same route.
97
+ auth = MultiAuth(bearer, session)
98
+
99
+ app = FastAPI()
100
+
101
+ @app.get("/me")
102
+ async def me(user=Security(auth)):
103
+ return user
104
+ ```
105
+
106
+ ## Features
107
+
108
+ - **BYO validator**: every source wraps a sync or async callable you provide; the library never touches your user model or database.
109
+ - **`MultiAuth`**: try several sources in order on a single route (e.g. web session cookie + API bearer token), with all schemes documented in OpenAPI.
110
+ - **Built-in sources**: covering the standard `fastapi.security` schemes:
111
+ - **`HTTPBearerAuth`**: bearer tokens with optional Stripe-style `user_`/`org_` prefixes to route different token types to different validators, plus `generate_token()` for secure token creation.
112
+ - **`APIKeyCookieAuth`**: cookie sessions with optional HMAC-SHA256 signing (via [itsdangerous](https://itsdangerous.palletsprojects.com/)), embedded expiry, and key rotation.
113
+ - **`APIKeyHeaderAuth`**: `X-API-Key`-style schemes, with **`APIKeyQueryAuth`** for legacy clients that can only pass a query parameter.
114
+ - **`HTTPBasicAuth`**: `validator(username, password)` with `WWW-Authenticate` realm support.
115
+ - **Token hashing helpers**: `hash_token`/`verify_token_hash` package the "store the hash, never the token" pattern with constant-time comparison.
116
+ - **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.
117
+ - **Security scopes**: `Security(auth, scopes=[...])` forwards the declared scopes to validators that accept a `scopes` parameter.
118
+ - **Correct HTTP semantics**: 401 with `WWW-Authenticate` challenges ([RFC 7235](https://datatracker.ietf.org/doc/html/rfc7235)) and 403 via `ForbiddenError`.
119
+ - **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).
120
+
121
+ ## Comparison
122
+
123
+ An honest map of where `fastapi-multiauth` sits. It is the composable layer between the raw primitives of `fastapi.security` and opinionated user frameworks. If you want ready-made `/register`/`/login` routes and a user model, you want a different tool.
124
+
125
+ | | `fastapi.security` (native) | **fastapi-multiauth** | [fastapi-users](https://github.com/fastapi-users/fastapi-users) | [AuthX](https://github.com/yezz123/authx) | [Authlib](https://authlib.org/) |
126
+ |---|---|---|---|---|---|
127
+ | Credential extraction + OpenAPI | ✅ | ✅ | ✅ | ✅ | ➖ |
128
+ | Bring-your-own validator | manual | ✅ | ❌ (own user model) | partial | manual |
129
+ | Multiple schemes on one route | manual | ✅ `MultiAuth` | ❌ | ❌ | ❌ |
130
+ | Signed cookie sessions (rotation, name-binding) | ❌ | ✅ | ✅ (own format) | ✅ (JWT in cookie) | ❌ |
131
+ | Opaque API tokens (prefixes, hash helpers) | ❌ | ✅ | ✅ (DB-backed) | ❌ | ❌ |
132
+ | JWT validation (HS + JWKS) | ❌ | ✅ `JWTValidator` | ✅ | ✅ | ✅ |
133
+ | JWT **issuance** / refresh | ❌ | ❌ | ✅ | ✅ | ✅ |
134
+ | Scope enforcement (fail-closed) | manual | ✅ | partial | ✅ | manual |
135
+ | OAuth login client helpers (PKCE, state) | ❌ | ✅ | ✅ (per-provider) | ❌ | ✅ (full client) |
136
+ | OAuth2/OIDC **server** | ❌ | ❌ | ❌ | ❌ | ✅ |
137
+ | User model, register/login/reset routes | ❌ | ❌ | ✅ | ❌ | ❌ |
138
+ | Database coupling | none | none | SQLAlchemy/Beanie | none | none |
139
+ | Maintenance status | active | active | maintenance mode | active | active |
140
+
141
+ When to pick what:
142
+
143
+ - **`fastapi.security` alone**: one scheme, simple validator, no sessions. The primitives are fine, this library just saves you the boilerplate around them.
144
+ - **fastapi-multiauth**: you own the user store and just need request-time auth: bring your own validator, combine several schemes on one route with `MultiAuth`, validate opaque tokens or JWTs, and get correct OpenAPI for free. No user model, no issued tokens, no database coupling.
145
+ - **fastapi-users**: you want batteries included (user table, password reset, verified-email flow) and accept its user model. Note it is in maintenance mode.
146
+ - **AuthX**: your session model is "login issues a JWT pair, refresh endpoint rotates it". This library validates JWTs but will never issue them.
147
+ - **Authlib**: you are building an OAuth2/OIDC *server*, or need a full-featured OAuth client beyond the login flow.
148
+
149
+ ## FAQ
150
+
151
+ ### Can I use it with sync validators?
152
+
153
+ Yes, every source accepts sync or async callables (including callable class instances) and awaits them correctly.
154
+
155
+ ### Why don't my route scopes show in Swagger UI?
156
+
157
+ The OpenAPI specification only allows scope lists on `oauth2`/`openIdConnect` schemes; for `http`/`apiKey` schemes the array must be empty. Enforcement happens at runtime regardless; see [Usage → Security scopes](https://fastapi-multiauth.d3vyce.fr/usage/#security-scopes).
158
+
159
+ ## License
160
+
161
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,22 @@
1
+ fastapi_multiauth/__init__.py,sha256=9-eT91gStv08_lbm067HDs1UhpJDI236fR5QlxDLfdw,1048
2
+ fastapi_multiauth/_http.py,sha256=7F9g2-jlFASna7ZPYNNemmc8rr4vS634z3vlf5-ngIg,1502
3
+ fastapi_multiauth/_imports.py,sha256=-DhUaPwk3ip5Vlk8kktg78C_Gzu1ZbtagEFtXGAytug,313
4
+ fastapi_multiauth/abc.py,sha256=q_qNLAVht29zidRHijBckk2iL7LR_2K7Z7BA-9TUmVc,8447
5
+ fastapi_multiauth/exceptions.py,sha256=Yov3UXuNTFDJzPrpRebjujZz9GjDzoG3wuK4FKiusAk,1923
6
+ fastapi_multiauth/jwt.py,sha256=VQEVCXpLFVYA_EQLZKaedRYfYzOafQyiqDi5uxmKASo,10528
7
+ fastapi_multiauth/oauth/__init__.py,sha256=dLf_gg2hjy_N0ufkWwl87bU4FkS188H0Cu_0ZHrL7DI,1177
8
+ fastapi_multiauth/oauth/client.py,sha256=b_n66iU3-uIrItrCGEl2I2NkjKOPBaQxVRNMktL1iMU,8834
9
+ fastapi_multiauth/oauth/utils.py,sha256=7cNg_y4rPUossvMgws_QedCWWHsa2-pcs8zpLUgys6A,5850
10
+ fastapi_multiauth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ fastapi_multiauth/sources/__init__.py,sha256=P0hvJtodjnlQCBbiuCD-Fqs8o4NpxwRjgNrQZ55y0Mo,407
12
+ fastapi_multiauth/sources/basic.py,sha256=bFlNaWjfXTLJTYB-wt9vyUQ8VdpfHJt-GrJUiDKO-Mk,2598
13
+ fastapi_multiauth/sources/bearer.py,sha256=DoQmm-4_H4Itu-t8v-Y9cITerCoBuSsyjc8ikHtEuYU,2916
14
+ fastapi_multiauth/sources/cookie.py,sha256=5HAd3pCwnY9UxKo8IbKTk1neFX-wGC0IWQu6uKIem6w,5817
15
+ fastapi_multiauth/sources/header.py,sha256=7_o3z7mYpuPj4sMsC2dmkYWA9Mp7xWq75Ia-DlGIpT8,1479
16
+ fastapi_multiauth/sources/multi.py,sha256=LgIHKRQaG30-TRNpUfXDcUceOSUrh2AFEpjVktKPnS8,3885
17
+ fastapi_multiauth/sources/query.py,sha256=IdjUvdXqVwIn_PCfEAk_mmXzEGVeNzZRbXxMIbniHZA,1731
18
+ fastapi_multiauth/utils.py,sha256=YRhWvpMT6cG2AaU4sjMaGuL_eX60_4nOjAKLbib42wA,2693
19
+ fastapi_multiauth-0.1.0.dist-info/licenses/LICENSE,sha256=V2jCjI-VPB-veGY2Ktb0sU4vT_TldRciZ9lCE98bMoE,1063
20
+ fastapi_multiauth-0.1.0.dist-info/WHEEL,sha256=0ksFqNncQTsCUyOQQNfyYN3tci86LvOLl92eMRFTR70,81
21
+ fastapi_multiauth-0.1.0.dist-info/METADATA,sha256=h8ZQVzVakJTOsBUwEXkZl1e2li_zXAslzLbte6wOn5Q,9412
22
+ fastapi_multiauth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.22
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 d3vyce
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.