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.
- fastapi_multiauth/__init__.py +42 -0
- fastapi_multiauth/_http.py +44 -0
- fastapi_multiauth/_imports.py +9 -0
- fastapi_multiauth/abc.py +220 -0
- fastapi_multiauth/exceptions.py +66 -0
- fastapi_multiauth/jwt.py +260 -0
- fastapi_multiauth/oauth/__init__.py +43 -0
- fastapi_multiauth/oauth/client.py +246 -0
- fastapi_multiauth/oauth/utils.py +159 -0
- fastapi_multiauth/py.typed +0 -0
- fastapi_multiauth/sources/__init__.py +17 -0
- fastapi_multiauth/sources/basic.py +68 -0
- fastapi_multiauth/sources/bearer.py +80 -0
- fastapi_multiauth/sources/cookie.py +153 -0
- fastapi_multiauth/sources/header.py +45 -0
- fastapi_multiauth/sources/multi.py +97 -0
- fastapi_multiauth/sources/query.py +50 -0
- fastapi_multiauth/utils.py +83 -0
- fastapi_multiauth-0.1.0.dist-info/METADATA +161 -0
- fastapi_multiauth-0.1.0.dist-info/RECORD +22 -0
- fastapi_multiauth-0.1.0.dist-info/WHEEL +4 -0
- fastapi_multiauth-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""OAuth 2.0 / OIDC network operations: discovery, code exchange, userinfo."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from typing import Any, Literal, NamedTuple
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from async_lru import alru_cache
|
|
8
|
+
|
|
9
|
+
from .._http import DEFAULT_TIMEOUT, _get_json, _require_https
|
|
10
|
+
from .._imports import require_extra
|
|
11
|
+
from ..exceptions import OAuthDiscoveryError, OAuthExchangeError, OAuthUserinfoError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"OIDCEndpoints",
|
|
15
|
+
"oauth_exchange_code",
|
|
16
|
+
"oauth_fetch_userinfo",
|
|
17
|
+
"oauth_resolve_provider_urls",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_DISCOVERY_SUFFIX = "/.well-known/openid-configuration"
|
|
21
|
+
_DISCOVERY_CACHE_TTL = 3600
|
|
22
|
+
|
|
23
|
+
TokenEndpointAuthMethod = Literal["client_secret_post", "client_secret_basic"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OIDCEndpoints(NamedTuple):
|
|
27
|
+
"""Validated endpoint URLs from an OIDC discovery document.
|
|
28
|
+
|
|
29
|
+
All URLs are HTTPS-checked (loopback hosts excepted). Optional fields are
|
|
30
|
+
``None`` when the provider does not advertise them.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
authorization_endpoint: str
|
|
34
|
+
token_endpoint: str
|
|
35
|
+
userinfo_endpoint: str | None
|
|
36
|
+
jwks_uri: str | None
|
|
37
|
+
end_session_endpoint: str | None
|
|
38
|
+
issuer: str | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_issuer(discovery_url: str, issuer: Any) -> None:
|
|
42
|
+
"""Check the discovery document claims the issuer it was fetched from."""
|
|
43
|
+
if not discovery_url.endswith(_DISCOVERY_SUFFIX):
|
|
44
|
+
return
|
|
45
|
+
expected = discovery_url.removesuffix(_DISCOVERY_SUFFIX).rstrip("/")
|
|
46
|
+
if not isinstance(issuer, str) or issuer.rstrip("/") != expected:
|
|
47
|
+
raise OAuthDiscoveryError(
|
|
48
|
+
f"discovery document issuer {issuer!r} does not match the "
|
|
49
|
+
f"expected issuer {expected!r} derived from the discovery URL"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@functools.lru_cache(maxsize=1)
|
|
54
|
+
def _import_httpx_oauth() -> Any:
|
|
55
|
+
"""Import ``httpx_oauth.oauth2`` lazily (the optional ``oauth`` extra)."""
|
|
56
|
+
try:
|
|
57
|
+
from httpx_oauth import oauth2
|
|
58
|
+
except ImportError: # pragma: no cover
|
|
59
|
+
require_extra("httpx-oauth", "oauth")
|
|
60
|
+
return oauth2
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@functools.lru_cache(maxsize=1)
|
|
64
|
+
def _oauth2_client_class() -> type:
|
|
65
|
+
"""Return an httpx-oauth ``OAuth2`` subclass with an explicit timeout."""
|
|
66
|
+
|
|
67
|
+
class _TimeoutOAuth2(_import_httpx_oauth().OAuth2):
|
|
68
|
+
"""OAuth2 client whose HTTP calls run under an explicit timeout."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
client_id: str,
|
|
73
|
+
client_secret: str,
|
|
74
|
+
access_token_endpoint: str,
|
|
75
|
+
timeout: float,
|
|
76
|
+
token_endpoint_auth_method: TokenEndpointAuthMethod,
|
|
77
|
+
) -> None:
|
|
78
|
+
super().__init__(
|
|
79
|
+
client_id,
|
|
80
|
+
client_secret,
|
|
81
|
+
# Only the token endpoint is used; the authorization redirect
|
|
82
|
+
# is built by oauth_build_authorization_redirect().
|
|
83
|
+
authorize_endpoint="",
|
|
84
|
+
access_token_endpoint=access_token_endpoint,
|
|
85
|
+
token_endpoint_auth_method=token_endpoint_auth_method,
|
|
86
|
+
)
|
|
87
|
+
self._timeout = timeout
|
|
88
|
+
|
|
89
|
+
def get_httpx_client(self) -> httpx.AsyncClient:
|
|
90
|
+
return httpx.AsyncClient(timeout=self._timeout)
|
|
91
|
+
|
|
92
|
+
return _TimeoutOAuth2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@alru_cache(maxsize=32, ttl=_DISCOVERY_CACHE_TTL)
|
|
96
|
+
async def oauth_resolve_provider_urls(
|
|
97
|
+
discovery_url: str,
|
|
98
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
99
|
+
) -> OIDCEndpoints:
|
|
100
|
+
"""Fetch the OIDC discovery document and return its validated endpoints.
|
|
101
|
+
|
|
102
|
+
Cached for one hour. The discovery URL must come from configuration, never
|
|
103
|
+
from request input (SSRF / cache-poisoning).
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
discovery_url: Provider's openid-configuration URL.
|
|
107
|
+
timeout: Discovery request timeout in seconds.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Validated :class:`OIDCEndpoints`; optional fields are ``None`` when absent.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: If *discovery_url* is not HTTPS.
|
|
114
|
+
OAuthDiscoveryError: If the document is unreachable, malformed, or
|
|
115
|
+
advertises non-HTTPS endpoints or a mismatched issuer.
|
|
116
|
+
"""
|
|
117
|
+
_require_https(discovery_url, "OIDC discovery URL")
|
|
118
|
+
cfg = await _get_json(
|
|
119
|
+
discovery_url,
|
|
120
|
+
timeout=timeout,
|
|
121
|
+
error_cls=OAuthDiscoveryError,
|
|
122
|
+
description="OIDC discovery document",
|
|
123
|
+
)
|
|
124
|
+
if not isinstance(cfg, dict):
|
|
125
|
+
raise OAuthDiscoveryError("OIDC discovery document is not a JSON object")
|
|
126
|
+
_validate_issuer(discovery_url, cfg.get("issuer"))
|
|
127
|
+
|
|
128
|
+
def _optional(key: str) -> str | None:
|
|
129
|
+
url = cfg.get(key)
|
|
130
|
+
return None if url is None else _require_https(url, f"OIDC {key}")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
return OIDCEndpoints(
|
|
134
|
+
authorization_endpoint=_require_https(
|
|
135
|
+
cfg["authorization_endpoint"], "OIDC authorization_endpoint"
|
|
136
|
+
),
|
|
137
|
+
token_endpoint=_require_https(cfg["token_endpoint"], "OIDC token_endpoint"),
|
|
138
|
+
userinfo_endpoint=_optional("userinfo_endpoint"),
|
|
139
|
+
jwks_uri=_optional("jwks_uri"),
|
|
140
|
+
end_session_endpoint=_optional("end_session_endpoint"),
|
|
141
|
+
issuer=cfg.get("issuer"),
|
|
142
|
+
)
|
|
143
|
+
except KeyError as exc:
|
|
144
|
+
raise OAuthDiscoveryError(
|
|
145
|
+
f"OIDC discovery document is missing the {exc.args[0]!r} endpoint"
|
|
146
|
+
) from exc
|
|
147
|
+
except ValueError as exc:
|
|
148
|
+
raise OAuthDiscoveryError(str(exc)) from exc
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def oauth_exchange_code(
|
|
152
|
+
*,
|
|
153
|
+
token_url: str,
|
|
154
|
+
code: str,
|
|
155
|
+
client_id: str,
|
|
156
|
+
client_secret: str,
|
|
157
|
+
redirect_uri: str,
|
|
158
|
+
required_scopes: str | None = None,
|
|
159
|
+
code_verifier: str | None = None,
|
|
160
|
+
token_endpoint_auth_method: TokenEndpointAuthMethod = "client_secret_post", # noqa: S107 — method name, not a secret
|
|
161
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
"""Exchange an authorization code for tokens and return the token response.
|
|
164
|
+
|
|
165
|
+
Delegates the exchange (including PKCE) to httpx-oauth (the ``oauth`` extra).
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
token_url: Provider's token endpoint.
|
|
169
|
+
code: Authorization code from the provider's callback.
|
|
170
|
+
client_id: OAuth application client ID.
|
|
171
|
+
client_secret: OAuth application client secret.
|
|
172
|
+
redirect_uri: Redirect URI used in the authorization request.
|
|
173
|
+
required_scopes: Space-separated scopes that must be present in the
|
|
174
|
+
token response (RFC 6749 §3.3).
|
|
175
|
+
code_verifier: PKCE code verifier matching the challenge sent earlier
|
|
176
|
+
(from :func:`~fastapi_multiauth.oauth.oauth_generate_pkce_pair`).
|
|
177
|
+
token_endpoint_auth_method: How credentials are sent — in the POST body
|
|
178
|
+
(``client_secret_post``, default) or as a Basic header.
|
|
179
|
+
timeout: Timeout in seconds for the token request.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The full token response as a ``dict`` (``access_token`` plus whatever
|
|
183
|
+
else the provider returned).
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: If *token_url* is not HTTPS.
|
|
187
|
+
OAuthExchangeError: If the exchange is rejected or fails, grants a
|
|
188
|
+
non-bearer token, or omits a required scope.
|
|
189
|
+
"""
|
|
190
|
+
_require_https(token_url, "OAuth token_url")
|
|
191
|
+
|
|
192
|
+
oauth_client = _oauth2_client_class()(
|
|
193
|
+
client_id, client_secret, token_url, timeout, token_endpoint_auth_method
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
token_data = await oauth_client.get_access_token(
|
|
197
|
+
code, redirect_uri, code_verifier=code_verifier
|
|
198
|
+
)
|
|
199
|
+
except _import_httpx_oauth().GetAccessTokenError as exc:
|
|
200
|
+
raise OAuthExchangeError(str(exc)) from exc
|
|
201
|
+
|
|
202
|
+
token_type = token_data.get("token_type", "bearer")
|
|
203
|
+
if not isinstance(token_type, str) or token_type.lower() != "bearer":
|
|
204
|
+
raise OAuthExchangeError(f"unsupported token_type: {token_type!r}")
|
|
205
|
+
|
|
206
|
+
if required_scopes is not None:
|
|
207
|
+
scope = token_data.get("scope", "")
|
|
208
|
+
# Fail closed on a malformed (non-string) scope field.
|
|
209
|
+
granted = set(scope.split()) if isinstance(scope, str) else set()
|
|
210
|
+
missing = set(required_scopes.split()) - granted
|
|
211
|
+
if missing:
|
|
212
|
+
raise OAuthExchangeError(
|
|
213
|
+
f"provider did not grant required scopes: {missing}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return dict(token_data)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def oauth_fetch_userinfo(
|
|
220
|
+
*,
|
|
221
|
+
userinfo_url: str,
|
|
222
|
+
access_token: str,
|
|
223
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
"""Fetch the userinfo payload with a bearer access token.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
userinfo_url: Provider's userinfo endpoint.
|
|
229
|
+
access_token: Access token from :func:`oauth_exchange_code`.
|
|
230
|
+
timeout: Timeout in seconds for the userinfo request.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: If *userinfo_url* is not HTTPS.
|
|
237
|
+
OAuthUserinfoError: If the endpoint fails or does not return JSON.
|
|
238
|
+
"""
|
|
239
|
+
_require_https(userinfo_url, "OAuth userinfo_url")
|
|
240
|
+
return await _get_json(
|
|
241
|
+
userinfo_url,
|
|
242
|
+
timeout=timeout,
|
|
243
|
+
error_cls=OAuthUserinfoError,
|
|
244
|
+
description="userinfo",
|
|
245
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
246
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Pure OAuth helpers: state, PKCE, and the authorization redirect (no I/O)."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from urllib.parse import urlencode, urlsplit
|
|
10
|
+
|
|
11
|
+
from fastapi import status
|
|
12
|
+
from fastapi.responses import RedirectResponse
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"oauth_build_authorization_redirect",
|
|
16
|
+
"oauth_decode_state",
|
|
17
|
+
"oauth_encode_state",
|
|
18
|
+
"oauth_generate_pkce_pair",
|
|
19
|
+
"oauth_generate_state_token",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def oauth_generate_state_token() -> str:
|
|
24
|
+
"""Generate a cryptographically random CSRF token for the OAuth ``state`` parameter."""
|
|
25
|
+
return secrets.token_urlsafe(32)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def oauth_generate_pkce_pair() -> tuple[str, str]:
|
|
29
|
+
"""Generate a PKCE ``(code_verifier, code_challenge)`` pair (RFC 7636, S256).
|
|
30
|
+
|
|
31
|
+
Store the verifier server-side and pass it back to
|
|
32
|
+
:func:`~fastapi_multiauth.oauth.oauth_exchange_code` on the callback.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
A ``(code_verifier, code_challenge)`` tuple; the challenge is the
|
|
36
|
+
base64url SHA-256 of the verifier.
|
|
37
|
+
"""
|
|
38
|
+
code_verifier = secrets.token_urlsafe(64)
|
|
39
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
40
|
+
code_challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
41
|
+
return code_verifier, code_challenge
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def oauth_build_authorization_redirect(
|
|
45
|
+
authorization_url: str,
|
|
46
|
+
*,
|
|
47
|
+
client_id: str,
|
|
48
|
+
scopes: str,
|
|
49
|
+
redirect_uri: str,
|
|
50
|
+
destination: str,
|
|
51
|
+
state_token: str,
|
|
52
|
+
code_challenge: str | None = None,
|
|
53
|
+
) -> RedirectResponse:
|
|
54
|
+
"""Return an OAuth 2.0 authorization ``RedirectResponse`` (303 See Other).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
authorization_url: Provider's authorization endpoint.
|
|
58
|
+
client_id: OAuth application client ID.
|
|
59
|
+
scopes: Space-separated list of requested scopes.
|
|
60
|
+
redirect_uri: URI the provider should redirect back to.
|
|
61
|
+
destination: Post-login URL (embedded in ``state``).
|
|
62
|
+
state_token: CSRF token from :func:`oauth_generate_state_token`; verify
|
|
63
|
+
it with :func:`oauth_decode_state` on the callback.
|
|
64
|
+
code_challenge: PKCE challenge from :func:`oauth_generate_pkce_pair`;
|
|
65
|
+
when set, ``code_challenge_method=S256`` is sent along.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
|
69
|
+
authorization page, retaining any query string already present.
|
|
70
|
+
"""
|
|
71
|
+
params = {
|
|
72
|
+
"client_id": client_id,
|
|
73
|
+
"response_type": "code",
|
|
74
|
+
"scope": scopes,
|
|
75
|
+
"redirect_uri": redirect_uri,
|
|
76
|
+
"state": oauth_encode_state(destination, state_token),
|
|
77
|
+
}
|
|
78
|
+
if code_challenge is not None:
|
|
79
|
+
params["code_challenge"] = code_challenge
|
|
80
|
+
params["code_challenge_method"] = "S256"
|
|
81
|
+
sep = "&" if urlsplit(authorization_url).query else "?"
|
|
82
|
+
return RedirectResponse(
|
|
83
|
+
f"{authorization_url}{sep}{urlencode(params)}",
|
|
84
|
+
status_code=status.HTTP_303_SEE_OTHER,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def oauth_encode_state(url: str, state_token: str) -> str:
|
|
89
|
+
"""Encode a destination URL and CSRF token into an OAuth ``state`` parameter.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
url: Post-login destination URL.
|
|
93
|
+
state_token: CSRF token from :func:`oauth_generate_state_token`.
|
|
94
|
+
"""
|
|
95
|
+
payload = json.dumps({"n": state_token, "d": url}, separators=(",", ":"))
|
|
96
|
+
return base64.urlsafe_b64encode(payload.encode()).decode()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _destination_allowed(destination: str, allowed_hosts: Sequence[str]) -> bool:
|
|
100
|
+
"""Check a decoded destination against the open-redirect allowlist."""
|
|
101
|
+
# Browsers normalize backslashes to slashes, turning "/\evil.com" into a
|
|
102
|
+
# scheme-relative redirect — reject them outright.
|
|
103
|
+
if "\\" in destination:
|
|
104
|
+
return False
|
|
105
|
+
parsed = urlsplit(destination)
|
|
106
|
+
if not parsed.scheme and not parsed.netloc:
|
|
107
|
+
return destination.startswith("/") and not destination.startswith("//")
|
|
108
|
+
if parsed.scheme not in ("http", "https"):
|
|
109
|
+
return False
|
|
110
|
+
return parsed.hostname in allowed_hosts
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def oauth_decode_state(
|
|
114
|
+
state: str | None,
|
|
115
|
+
*,
|
|
116
|
+
expected_state_token: str,
|
|
117
|
+
fallback: str,
|
|
118
|
+
allowed_hosts: Sequence[str] | None = (),
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Decode and CSRF-verify an OAuth ``state`` parameter (constant-time).
|
|
121
|
+
|
|
122
|
+
The stored token is single-use: delete it from the session after this call,
|
|
123
|
+
matched or not, so a captured callback cannot be replayed.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
state: Raw ``state`` query parameter from the callback.
|
|
127
|
+
expected_state_token: Token stored before the authorization redirect;
|
|
128
|
+
a mismatch returns ``fallback``.
|
|
129
|
+
fallback: URL returned when ``state`` is absent, malformed, or fails
|
|
130
|
+
verification.
|
|
131
|
+
allowed_hosts: Open-redirect guard — the decoded destination must be a
|
|
132
|
+
relative path or an absolute http(s) URL whose host is listed. The
|
|
133
|
+
default (``()``) allows relative paths only; ``None`` disables the check.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The destination URL embedded in ``state``, or ``fallback``.
|
|
137
|
+
"""
|
|
138
|
+
if not state or state == "null": # "null" guards against JS JSON.stringify(null)
|
|
139
|
+
return fallback
|
|
140
|
+
try:
|
|
141
|
+
padded = state + "=" * (-len(state) % 4)
|
|
142
|
+
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
|
143
|
+
if not isinstance(payload, dict):
|
|
144
|
+
return fallback
|
|
145
|
+
token = payload.get("n", "")
|
|
146
|
+
if not isinstance(token, str) or not hmac.compare_digest(
|
|
147
|
+
token.encode(), expected_state_token.encode()
|
|
148
|
+
):
|
|
149
|
+
return fallback
|
|
150
|
+
destination = payload["d"]
|
|
151
|
+
if not isinstance(destination, str):
|
|
152
|
+
return fallback
|
|
153
|
+
if allowed_hosts is not None and not _destination_allowed(
|
|
154
|
+
destination, allowed_hosts
|
|
155
|
+
):
|
|
156
|
+
return fallback
|
|
157
|
+
return destination
|
|
158
|
+
except (UnicodeDecodeError, ValueError, KeyError):
|
|
159
|
+
return fallback
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Built-in authentication source implementations."""
|
|
2
|
+
|
|
3
|
+
from .basic import HTTPBasicAuth
|
|
4
|
+
from .bearer import HTTPBearerAuth
|
|
5
|
+
from .cookie import APIKeyCookieAuth
|
|
6
|
+
from .header import APIKeyHeaderAuth
|
|
7
|
+
from .multi import MultiAuth
|
|
8
|
+
from .query import APIKeyQueryAuth
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"APIKeyCookieAuth",
|
|
12
|
+
"APIKeyHeaderAuth",
|
|
13
|
+
"APIKeyQueryAuth",
|
|
14
|
+
"HTTPBasicAuth",
|
|
15
|
+
"HTTPBearerAuth",
|
|
16
|
+
"MultiAuth",
|
|
17
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""HTTP Basic authentication source."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from fastapi import Request
|
|
7
|
+
from fastapi.security import HTTPBasic
|
|
8
|
+
|
|
9
|
+
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
10
|
+
|
|
11
|
+
from ..abc import ValidatedAuthSource
|
|
12
|
+
from ..utils import authorization_credential
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HTTPBasicAuth(ValidatedAuthSource):
|
|
16
|
+
"""HTTP Basic authentication source (wraps ``HTTPBasic`` for OpenAPI, RFC 7617).
|
|
17
|
+
|
|
18
|
+
The validator is called as ``await validator(username, password, **kwargs)``;
|
|
19
|
+
compare the password in constant time (``secrets.compare_digest``).
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
validator: Sync or async callable receiving ``(username, password)`` and
|
|
23
|
+
returning the identity; raises
|
|
24
|
+
:class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
|
|
25
|
+
realm: Optional protection-space name for the ``WWW-Authenticate`` challenge.
|
|
26
|
+
scheme_name: OpenAPI security scheme name (default ``HTTPBasic``).
|
|
27
|
+
**kwargs: Extra keyword arguments forwarded to the validator on every call.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
validator: Callable[..., Any],
|
|
33
|
+
*,
|
|
34
|
+
realm: str | None = None,
|
|
35
|
+
scheme_name: str | None = None,
|
|
36
|
+
**kwargs: Any,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._realm = realm
|
|
39
|
+
super().__init__(
|
|
40
|
+
validator,
|
|
41
|
+
HTTPBasic(auto_error=False, scheme_name=scheme_name),
|
|
42
|
+
**kwargs,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def www_authenticate(self) -> str:
|
|
46
|
+
"""``Basic`` challenge, with the realm when configured (RFC 7617 §2)."""
|
|
47
|
+
if self._realm is not None:
|
|
48
|
+
return f'Basic realm="{self._realm}"'
|
|
49
|
+
return "Basic"
|
|
50
|
+
|
|
51
|
+
async def extract(self, request: Request) -> str | None:
|
|
52
|
+
"""Return the base64 credentials blob, or ``None`` when absent or empty.
|
|
53
|
+
|
|
54
|
+
The ``Basic`` scheme is matched case-insensitively; decoding and the
|
|
55
|
+
split happen in :meth:`authenticate`, so a malformed blob is a 401.
|
|
56
|
+
"""
|
|
57
|
+
return authorization_credential(request, "basic")
|
|
58
|
+
|
|
59
|
+
async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
|
|
60
|
+
"""Decode the blob, forwarding route-declared scopes to the validator."""
|
|
61
|
+
try:
|
|
62
|
+
decoded = base64.b64decode(credential, validate=True).decode("utf-8")
|
|
63
|
+
except ValueError: # covers binascii.Error and UnicodeDecodeError
|
|
64
|
+
raise UnauthorizedError() from None
|
|
65
|
+
username, sep, password = decoded.partition(":")
|
|
66
|
+
if not sep:
|
|
67
|
+
raise UnauthorizedError()
|
|
68
|
+
return await self._call_validator(username, password, scopes=scopes)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Bearer token authentication source."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from fastapi import Request
|
|
7
|
+
from fastapi.security import HTTPBearer
|
|
8
|
+
|
|
9
|
+
from fastapi_multiauth.exceptions import UnauthorizedError
|
|
10
|
+
|
|
11
|
+
from ..abc import ValidatedAuthSource
|
|
12
|
+
from ..utils import authorization_credential
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HTTPBearerAuth(ValidatedAuthSource):
|
|
16
|
+
"""Bearer token authentication source (wraps ``HTTPBearer`` for OpenAPI).
|
|
17
|
+
|
|
18
|
+
The validator is called as ``await validator(credential, **kwargs)``.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
validator: Sync or async callable returning the identity; raises
|
|
22
|
+
:class:`~fastapi_multiauth.exceptions.UnauthorizedError` on failure.
|
|
23
|
+
prefix: Optional token prefix (e.g. ``"user_"``); only tokens with this
|
|
24
|
+
prefix are matched, and the prefix is kept in the validated value.
|
|
25
|
+
scheme_name: OpenAPI security scheme name (default ``HTTPBearer``); give
|
|
26
|
+
each source a distinct name when several appear in the same app.
|
|
27
|
+
**kwargs: Extra keyword arguments forwarded to the validator on every call.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
validator: Callable[..., Any],
|
|
33
|
+
*,
|
|
34
|
+
prefix: str | None = None,
|
|
35
|
+
scheme_name: str | None = None,
|
|
36
|
+
**kwargs: Any,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._prefix = prefix
|
|
39
|
+
super().__init__(
|
|
40
|
+
validator,
|
|
41
|
+
HTTPBearer(auto_error=False, scheme_name=scheme_name),
|
|
42
|
+
**kwargs,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def www_authenticate(self) -> str:
|
|
46
|
+
"""``Bearer`` challenge per RFC 6750 §3."""
|
|
47
|
+
return "Bearer"
|
|
48
|
+
|
|
49
|
+
async def extract(self, request: Request) -> str | None:
|
|
50
|
+
"""Return the bearer token, or ``None`` when absent, empty, or mismatched.
|
|
51
|
+
|
|
52
|
+
The ``Bearer`` scheme is matched case-insensitively; the prefix, when
|
|
53
|
+
configured, is kept in the returned value.
|
|
54
|
+
"""
|
|
55
|
+
token = authorization_credential(request, "bearer")
|
|
56
|
+
if token is None:
|
|
57
|
+
return None
|
|
58
|
+
if self._prefix is not None and not token.startswith(self._prefix):
|
|
59
|
+
return None
|
|
60
|
+
return token
|
|
61
|
+
|
|
62
|
+
async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
|
|
63
|
+
"""Validate the credential, re-checking the prefix and forwarding scopes."""
|
|
64
|
+
if self._prefix is not None and not credential.startswith(self._prefix):
|
|
65
|
+
raise UnauthorizedError()
|
|
66
|
+
return await self._call_validator(credential, scopes=scopes)
|
|
67
|
+
|
|
68
|
+
def generate_token(self, nbytes: int = 32) -> str:
|
|
69
|
+
"""Generate a secure URL-safe random token, prefixed when configured.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
nbytes: Number of random bytes before base64 encoding (default 32).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A ready-to-use token string (e.g. ``"user_Xk3..."``).
|
|
76
|
+
"""
|
|
77
|
+
token = secrets.token_urlsafe(nbytes)
|
|
78
|
+
if self._prefix is not None:
|
|
79
|
+
return f"{self._prefix}{token}"
|
|
80
|
+
return token
|