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,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