mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-04 15:44:09 +00:00
chore: remove security module
This commit is contained in:
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
|
|
||||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
|
|
||||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
# Security
|
|
||||||
|
|
||||||
Composable authentication helpers for FastAPI that use `Security()` for OpenAPI documentation and accept user-provided validator functions with full type flexibility.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The `security` module provides four auth source classes, a `MultiAuth` factory, and a set of OAuth 2.0 / OIDC helper utilities. Each auth class wraps a FastAPI security scheme for OpenAPI and accepts a validator function called as:
|
|
||||||
|
|
||||||
```python
|
|
||||||
await validator(credential, **kwargs)
|
|
||||||
```
|
|
||||||
|
|
||||||
where `kwargs` are the extra keyword arguments provided at instantiation (roles, permissions, enums, etc.). The validator returns the authenticated identity (e.g. a `User` model) which becomes the route dependency value.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import Security
|
|
||||||
from fastapi_toolsets.security import BearerTokenAuth
|
|
||||||
|
|
||||||
async def verify_token(token: str, *, role: str) -> User:
|
|
||||||
user = await db.get_by_token(token)
|
|
||||||
if not user or user.role != role:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return user
|
|
||||||
|
|
||||||
bearer_admin = BearerTokenAuth(verify_token, role="admin")
|
|
||||||
|
|
||||||
@app.get("/admin")
|
|
||||||
async def admin_route(user: User = Security(bearer_admin)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auth sources
|
|
||||||
|
|
||||||
### [`BearerTokenAuth`](../reference/security.md#fastapi_toolsets.security.BearerTokenAuth)
|
|
||||||
|
|
||||||
Reads the `Authorization: Bearer <token>` header. Wraps `HTTPBearer` for OpenAPI.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import BearerTokenAuth
|
|
||||||
|
|
||||||
bearer = BearerTokenAuth(validator=verify_token)
|
|
||||||
|
|
||||||
@app.get("/me")
|
|
||||||
async def me(user: User = Security(bearer)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Token prefix
|
|
||||||
|
|
||||||
The optional `prefix` parameter restricts a `BearerTokenAuth` instance to tokens that start with a given string. The prefix is **kept** in the value passed to the validator — store and compare tokens with their prefix included.
|
|
||||||
|
|
||||||
This lets you deploy multiple `BearerTokenAuth` instances in the same application and disambiguate them efficiently in `MultiAuth`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
|
|
||||||
org_bearer = BearerTokenAuth(verify_org, prefix="org_") # matches "Bearer org_..."
|
|
||||||
```
|
|
||||||
|
|
||||||
Use [`generate_token()`](#token-generation) to create correctly-prefixed tokens.
|
|
||||||
|
|
||||||
#### Token generation
|
|
||||||
|
|
||||||
`BearerTokenAuth.generate_token()` produces a secure random token ready to store in your database and return to the client. If a prefix is configured it is prepended automatically:
|
|
||||||
|
|
||||||
```python
|
|
||||||
bearer = BearerTokenAuth(verify_token, prefix="user_")
|
|
||||||
|
|
||||||
token = bearer.generate_token() # e.g. "user_Xk3mN..."
|
|
||||||
await db.store_token(user_id, token)
|
|
||||||
return {"access_token": token, "token_type": "bearer"}
|
|
||||||
```
|
|
||||||
|
|
||||||
The client sends `Authorization: Bearer user_Xk3mN...` and the validator receives the full token (prefix included) to compare against the stored value.
|
|
||||||
|
|
||||||
### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth)
|
|
||||||
|
|
||||||
Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI.
|
|
||||||
|
|
||||||
Cookies are issued with the `Secure` flag set by default, meaning they are only transmitted over HTTPS. Set `secure=False` when running locally over plain HTTP:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import CookieAuth
|
|
||||||
|
|
||||||
# Production (HTTPS) — default
|
|
||||||
cookie_auth = CookieAuth("session", validator=verify_session)
|
|
||||||
|
|
||||||
# Local development (HTTP only)
|
|
||||||
cookie_auth = CookieAuth("session", validator=verify_session, secure=False)
|
|
||||||
|
|
||||||
@app.get("/me")
|
|
||||||
async def me(user: User = Security(cookie_auth)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Signed cookies
|
|
||||||
|
|
||||||
Pass `secret_key` to enable HMAC-SHA256 signed, tamper-proof cookies. The cookie payload includes an expiry timestamp (`ttl`, default 24 h). No database entry is required — the signature is self-contained.
|
|
||||||
|
|
||||||
Use `set_cookie()` to issue the signed cookie on login and `delete_cookie()` to clear it on logout:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Production
|
|
||||||
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret")
|
|
||||||
|
|
||||||
# Local development
|
|
||||||
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret", secure=False)
|
|
||||||
|
|
||||||
@app.post("/login")
|
|
||||||
async def login(response: Response):
|
|
||||||
cookie_auth.set_cookie(response, user_id)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@app.post("/logout")
|
|
||||||
async def logout(response: Response):
|
|
||||||
cookie_auth.delete_cookie(response)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@app.get("/me")
|
|
||||||
async def me(user: User = Security(cookie_auth)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
When `secret_key` is not set, the raw cookie value is passed directly to the validator (stateful session behaviour — you manage the session store).
|
|
||||||
|
|
||||||
### [`APIKeyHeaderAuth`](../reference/security.md#fastapi_toolsets.security.APIKeyHeaderAuth)
|
|
||||||
|
|
||||||
Reads an API key from a named HTTP header. Wraps `APIKeyHeader` for OpenAPI.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import APIKeyHeaderAuth
|
|
||||||
|
|
||||||
api_key_auth = APIKeyHeaderAuth("X-API-Key", validator=verify_api_key)
|
|
||||||
|
|
||||||
@app.get("/data")
|
|
||||||
async def data(user: User = Security(api_key_auth)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
The header name is configurable — use any header your API defines (e.g. `"X-API-Key"`, `"Authorization"`, `"X-Service-Token"`).
|
|
||||||
|
|
||||||
## Typed validator kwargs
|
|
||||||
|
|
||||||
All auth classes forward extra instantiation keyword arguments to the validator. Arguments can be any type — enums, strings, integers, etc. The validator returns the authenticated identity, which FastAPI injects directly into the route handler.
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def verify_token(token: str, *, role: Role, permission: str) -> User:
|
|
||||||
user = await decode_token(token)
|
|
||||||
if user.role != role or permission not in user.permissions:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return user
|
|
||||||
|
|
||||||
bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read")
|
|
||||||
```
|
|
||||||
|
|
||||||
Each auth instance is self-contained — create a separate instance per distinct requirement instead of passing requirements through `Security(scopes=[...])`.
|
|
||||||
|
|
||||||
### Using `.require()` inline
|
|
||||||
|
|
||||||
If declaring a new top-level variable per role feels verbose, use `.require()` to create a configured clone directly in the route decorator. The original instance is not mutated:
|
|
||||||
|
|
||||||
```python
|
|
||||||
bearer = BearerTokenAuth(verify_token)
|
|
||||||
|
|
||||||
@app.get("/admin/stats")
|
|
||||||
async def admin_stats(user: User = Security(bearer.require(role=Role.ADMIN))):
|
|
||||||
return {"message": f"Hello admin {user.name}"}
|
|
||||||
|
|
||||||
@app.get("/profile")
|
|
||||||
async def profile(user: User = Security(bearer.require(role=Role.USER))):
|
|
||||||
return {"id": user.id, "name": user.name}
|
|
||||||
```
|
|
||||||
|
|
||||||
`.require()` kwargs are merged over existing ones — new values win on conflict.
|
|
||||||
The `prefix` (for `BearerTokenAuth`), cookie name and `secret_key` (for
|
|
||||||
`CookieAuth`), and header name (for `APIKeyHeaderAuth`) are always preserved.
|
|
||||||
|
|
||||||
## MultiAuth
|
|
||||||
|
|
||||||
[`MultiAuth`](../reference/security.md#fastapi_toolsets.security.MultiAuth) combines multiple auth sources into a single callable. Sources are tried in order; the first one that finds a credential wins.
|
|
||||||
|
|
||||||
If a credential is extracted but the validator raises, the exception propagates immediately — the remaining sources are **not** tried. This prevents silent fallthrough on invalid credentials.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import MultiAuth
|
|
||||||
|
|
||||||
multi = MultiAuth(user_bearer, org_bearer, cookie_auth)
|
|
||||||
|
|
||||||
@app.get("/data")
|
|
||||||
async def data_route(user = Security(multi)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using `.require()` on MultiAuth
|
|
||||||
|
|
||||||
`MultiAuth` also supports `.require()`, which propagates the kwargs to every source that implements it. Sources that do not (e.g. custom `AuthSource` subclasses) are passed through unchanged:
|
|
||||||
|
|
||||||
```python
|
|
||||||
multi = MultiAuth(bearer, cookie)
|
|
||||||
|
|
||||||
@app.get("/admin")
|
|
||||||
async def admin(user: User = Security(multi.require(role=Role.ADMIN))):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
This is equivalent to calling `.require()` on each source individually:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# These two are identical
|
|
||||||
multi.require(role=Role.ADMIN)
|
|
||||||
|
|
||||||
MultiAuth(
|
|
||||||
bearer.require(role=Role.ADMIN),
|
|
||||||
cookie.require(role=Role.ADMIN),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prefix-based dispatch
|
|
||||||
|
|
||||||
Because `extract()` is pure string matching (no I/O), prefix-based source selection is essentially free. Only the matching source's validator (which may involve DB or network I/O) is ever called:
|
|
||||||
|
|
||||||
```python
|
|
||||||
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
|
||||||
org_bearer = BearerTokenAuth(verify_org, prefix="org_")
|
|
||||||
|
|
||||||
multi = MultiAuth(user_bearer, org_bearer)
|
|
||||||
|
|
||||||
# "Bearer user_alice" → only verify_user runs, receives "user_alice"
|
|
||||||
# "Bearer org_acme" → only verify_org runs, receives "org_acme"
|
|
||||||
```
|
|
||||||
|
|
||||||
Tokens are stored and compared **with their prefix** — use `generate_token()` on each source to issue correctly-prefixed tokens:
|
|
||||||
|
|
||||||
```python
|
|
||||||
user_token = user_bearer.generate_token() # "user_..."
|
|
||||||
org_token = org_bearer.generate_token() # "org_..."
|
|
||||||
```
|
|
||||||
|
|
||||||
## Custom auth sources
|
|
||||||
|
|
||||||
Subclass [`AuthSource`](../reference/security.md#fastapi_toolsets.security.AuthSource) to implement any credential extraction strategy. You only need to implement `extract()` and `authenticate()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import AuthSource
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
class MTLSAuth(AuthSource):
|
|
||||||
async def extract(self, request) -> str | None:
|
|
||||||
return request.headers.get("X-Client-Cert-DN") or None
|
|
||||||
|
|
||||||
async def authenticate(self, credential: str):
|
|
||||||
dn = parse_dn(credential)
|
|
||||||
if dn.get("O") != "MyOrg":
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return {"dn": credential}
|
|
||||||
```
|
|
||||||
|
|
||||||
Custom sources work transparently inside `MultiAuth`.
|
|
||||||
|
|
||||||
## OAuth 2.0 / OIDC helpers
|
|
||||||
|
|
||||||
The module provides standalone async utilities for building OAuth 2.0 / OIDC login flows. They handle provider discovery, authorization redirects, token exchange, and state encoding — leaving JWT validation and session management to your application.
|
|
||||||
|
|
||||||
### Provider discovery
|
|
||||||
|
|
||||||
[`oauth_resolve_provider_urls()`](../reference/security.md#fastapi_toolsets.security.oauth_resolve_provider_urls) fetches the OIDC discovery document and returns the endpoint URLs. Results are cached in-process to avoid repeated network calls:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import oauth_resolve_provider_urls
|
|
||||||
|
|
||||||
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
|
|
||||||
"https://accounts.google.com/.well-known/openid-configuration"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url` is `None` when the provider does not advertise one.
|
|
||||||
|
|
||||||
### Authorization redirect
|
|
||||||
|
|
||||||
[`oauth_build_authorization_redirect()`](../reference/security.md#fastapi_toolsets.security.oauth_build_authorization_redirect) constructs the redirect to the provider's authorization page. It requires a `state_token` — a random CSRF token generated by [`oauth_generate_state_token()`](../reference/security.md#fastapi_toolsets.security.oauth_generate_state_token) — that must be stored server-side (e.g. in the session) and verified on the callback to prevent login-CSRF attacks ([RFC 6749 §10.12](https://datatracker.ietf.org/doc/html/rfc6749#section-10.12)):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_state_token
|
|
||||||
|
|
||||||
@app.get("/auth/google/login")
|
|
||||||
async def google_login(request: Request):
|
|
||||||
auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
|
|
||||||
state_token = oauth_generate_state_token()
|
|
||||||
request.session["oauth_state"] = state_token # requires SessionMiddleware
|
|
||||||
return oauth_build_authorization_redirect(
|
|
||||||
auth_url,
|
|
||||||
client_id=GOOGLE_CLIENT_ID,
|
|
||||||
scopes="openid email profile",
|
|
||||||
redirect_uri="https://myapp.com/auth/google/callback",
|
|
||||||
destination="/dashboard",
|
|
||||||
state_token=state_token,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Token exchange and userinfo
|
|
||||||
|
|
||||||
[`oauth_fetch_userinfo()`](../reference/security.md#fastapi_toolsets.security.oauth_fetch_userinfo) performs the two-step exchange: it POSTs the authorization code to the token endpoint, then GETs the userinfo endpoint with the resulting access token.
|
|
||||||
|
|
||||||
On the callback, retrieve the stored token and pass it to [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) to verify the CSRF token before processing the code:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import HTTPException, Request
|
|
||||||
from fastapi_toolsets.security import oauth_decode_state, oauth_fetch_userinfo
|
|
||||||
|
|
||||||
@app.get("/auth/google/callback")
|
|
||||||
async def google_callback(request: Request, code: str, state: str):
|
|
||||||
# Pop token first — single-use, regardless of whether verification succeeds
|
|
||||||
state_token = request.session.pop("oauth_state", None)
|
|
||||||
if state_token is None:
|
|
||||||
raise HTTPException(status_code=400, detail="missing OAuth state")
|
|
||||||
destination = oauth_decode_state(state, expected_state_token=state_token, fallback="/")
|
|
||||||
if not destination.startswith("/"): # reject absolute URLs to prevent open-redirect
|
|
||||||
destination = "/"
|
|
||||||
|
|
||||||
_, token_url, userinfo_url = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
|
|
||||||
userinfo = await oauth_fetch_userinfo(
|
|
||||||
token_url=token_url,
|
|
||||||
userinfo_url=userinfo_url,
|
|
||||||
code=code,
|
|
||||||
client_id=GOOGLE_CLIENT_ID,
|
|
||||||
client_secret=GOOGLE_CLIENT_SECRET,
|
|
||||||
redirect_uri="https://myapp.com/auth/google/callback",
|
|
||||||
required_scopes="openid email profile",
|
|
||||||
)
|
|
||||||
user = await db.upsert_user(email=userinfo["email"])
|
|
||||||
response = RedirectResponse(destination)
|
|
||||||
session_cookie.set_cookie(response, str(user.id))
|
|
||||||
return response
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass `required_scopes` to guard against providers silently granting fewer scopes than requested — `oauth_fetch_userinfo` raises `ValueError` if any are missing.
|
|
||||||
|
|
||||||
### State encoding
|
|
||||||
|
|
||||||
[`oauth_encode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_encode_state) and [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) encode and decode the destination URL together with the CSRF token embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the token does not match:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state
|
|
||||||
|
|
||||||
state_token = oauth_generate_state_token()
|
|
||||||
encoded = oauth_encode_state("/dashboard", state_token)
|
|
||||||
decoded = oauth_decode_state(encoded, expected_state_token=state_token, fallback="/") # "/dashboard"
|
|
||||||
decoded = oauth_decode_state(encoded, expected_state_token="wrong", fallback="/") # "/"
|
|
||||||
decoded = oauth_decode_state(None, expected_state_token=state_token, fallback="/") # "/"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/security.md)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# `security`
|
|
||||||
|
|
||||||
Here's the reference for the authentication helpers provided by the `security` module.
|
|
||||||
|
|
||||||
You can import them directly from `fastapi_toolsets.security`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import (
|
|
||||||
AuthSource,
|
|
||||||
BearerTokenAuth,
|
|
||||||
CookieAuth,
|
|
||||||
APIKeyHeaderAuth,
|
|
||||||
MultiAuth,
|
|
||||||
oauth_build_authorization_redirect,
|
|
||||||
oauth_decode_state,
|
|
||||||
oauth_encode_state,
|
|
||||||
oauth_fetch_userinfo,
|
|
||||||
oauth_generate_state_token,
|
|
||||||
oauth_resolve_provider_urls,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.AuthSource
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.BearerTokenAuth
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.CookieAuth
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.APIKeyHeaderAuth
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.MultiAuth
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_resolve_provider_urls
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_fetch_userinfo
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_generate_state_token
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_encode_state
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_decode_state
|
|
||||||
+1
-5
@@ -50,17 +50,13 @@ cli = [
|
|||||||
metrics = [
|
metrics = [
|
||||||
"prometheus_client>=0.20.0",
|
"prometheus_client>=0.20.0",
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
"async-lru>=1.0",
|
|
||||||
"httpx>=0.25.0",
|
|
||||||
]
|
|
||||||
pytest = [
|
pytest = [
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"pytest-xdist>=3.0.0",
|
"pytest-xdist>=3.0.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"fastapi-toolsets[cli,metrics,pytest,security]",
|
"fastapi-toolsets[cli,metrics,pytest]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
"""Authentication helpers for FastAPI using Security()."""
|
|
||||||
|
|
||||||
from .abc import AuthSource
|
|
||||||
from .oauth import (
|
|
||||||
oauth_build_authorization_redirect,
|
|
||||||
oauth_decode_state,
|
|
||||||
oauth_encode_state,
|
|
||||||
oauth_fetch_userinfo,
|
|
||||||
oauth_generate_state_token,
|
|
||||||
oauth_resolve_provider_urls,
|
|
||||||
)
|
|
||||||
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"APIKeyHeaderAuth",
|
|
||||||
"AuthSource",
|
|
||||||
"BearerTokenAuth",
|
|
||||||
"CookieAuth",
|
|
||||||
"MultiAuth",
|
|
||||||
"oauth_build_authorization_redirect",
|
|
||||||
"oauth_decode_state",
|
|
||||||
"oauth_encode_state",
|
|
||||||
"oauth_fetch_userinfo",
|
|
||||||
"oauth_generate_state_token",
|
|
||||||
"oauth_resolve_provider_urls",
|
|
||||||
]
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Abstract base class for authentication sources."""
|
|
||||||
|
|
||||||
import functools
|
|
||||||
import inspect
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.security import SecurityScopes
|
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
||||||
"""Wrap *fn* so it can always be awaited, caching the coroutine check at init time."""
|
|
||||||
if inspect.iscoroutinefunction(fn):
|
|
||||||
return fn
|
|
||||||
|
|
||||||
@functools.wraps(fn)
|
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
return fn(*args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class AuthSource(ABC):
|
|
||||||
"""Abstract base class for authentication sources."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Set up the default FastAPI dependency signature."""
|
|
||||||
source = self
|
|
||||||
|
|
||||||
async def _call(
|
|
||||||
request: Request,
|
|
||||||
security_scopes: SecurityScopes, # noqa: ARG001
|
|
||||||
) -> Any:
|
|
||||||
credential = await source.extract(request)
|
|
||||||
if credential is None:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return await source.authenticate(credential)
|
|
||||||
|
|
||||||
self._call_fn: Callable[..., Any] = _call
|
|
||||||
self.__signature__ = inspect.signature(_call)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def extract(self, request: Request) -> str | None:
|
|
||||||
"""Extract the raw credential from the request without validating."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def authenticate(self, credential: str) -> Any:
|
|
||||||
"""Validate a credential and return the authenticated identity."""
|
|
||||||
|
|
||||||
async def __call__(self, **kwargs: Any) -> Any:
|
|
||||||
"""FastAPI dependency dispatch."""
|
|
||||||
return await self._call_fn(**kwargs)
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
"""OAuth 2.0 / OIDC helper utilities."""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from async_lru import alru_cache
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
|
|
||||||
@alru_cache(maxsize=32)
|
|
||||||
async def oauth_resolve_provider_urls(
|
|
||||||
discovery_url: str,
|
|
||||||
) -> tuple[str, str, str | None]:
|
|
||||||
"""Fetch the OIDC discovery document and return endpoint URLs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
discovery_url: URL of the provider's ``/.well-known/openid-configuration``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A ``(authorization_url, token_url, userinfo_url)`` tuple.
|
|
||||||
*userinfo_url* is ``None`` when the provider does not advertise one.
|
|
||||||
"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(discovery_url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
cfg = resp.json()
|
|
||||||
return (
|
|
||||||
cfg["authorization_endpoint"],
|
|
||||||
cfg["token_endpoint"],
|
|
||||||
cfg.get("userinfo_endpoint"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def oauth_fetch_userinfo(
|
|
||||||
*,
|
|
||||||
token_url: str,
|
|
||||||
userinfo_url: str,
|
|
||||||
code: str,
|
|
||||||
client_id: str,
|
|
||||||
client_secret: str,
|
|
||||||
redirect_uri: str,
|
|
||||||
required_scopes: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Exchange an authorization code for tokens and return the userinfo payload.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token_url: Provider's token endpoint.
|
|
||||||
userinfo_url: Provider's userinfo endpoint.
|
|
||||||
code: Authorization code received from the provider's callback.
|
|
||||||
client_id: OAuth application client ID.
|
|
||||||
client_secret: OAuth application client secret.
|
|
||||||
redirect_uri: Redirect URI that was used in the authorization request.
|
|
||||||
required_scopes: Space-separated scopes that must be present in the token
|
|
||||||
response ``scope`` field (RFC 6749 §3.3). Raises ``ValueError`` if
|
|
||||||
the provider granted fewer scopes than requested.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the provider granted a different token type than ``bearer``
|
|
||||||
or did not grant all ``required_scopes``.
|
|
||||||
"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
token_resp = await client.post(
|
|
||||||
token_url,
|
|
||||||
data={
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": code,
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
)
|
|
||||||
token_resp.raise_for_status()
|
|
||||||
token_data = token_resp.json()
|
|
||||||
|
|
||||||
if token_data.get("token_type", "bearer").lower() != "bearer":
|
|
||||||
raise ValueError(
|
|
||||||
f"unsupported token_type: {token_data.get('token_type')!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if required_scopes is not None:
|
|
||||||
granted = set(token_data.get("scope", "").split())
|
|
||||||
missing = set(required_scopes.split()) - granted
|
|
||||||
if missing:
|
|
||||||
raise ValueError(f"provider did not grant required scopes: {missing}")
|
|
||||||
|
|
||||||
access_token = token_data["access_token"]
|
|
||||||
|
|
||||||
userinfo_resp = await client.get(
|
|
||||||
userinfo_url,
|
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
|
||||||
)
|
|
||||||
userinfo_resp.raise_for_status()
|
|
||||||
return userinfo_resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_generate_state_token() -> str:
|
|
||||||
"""Generate a cryptographically random CSRF token for the OAuth ``state`` parameter."""
|
|
||||||
return secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_build_authorization_redirect(
|
|
||||||
authorization_url: str,
|
|
||||||
*,
|
|
||||||
client_id: str,
|
|
||||||
scopes: str,
|
|
||||||
redirect_uri: str,
|
|
||||||
destination: str,
|
|
||||||
state_token: str,
|
|
||||||
) -> RedirectResponse:
|
|
||||||
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
authorization_url: Provider's authorization endpoint.
|
|
||||||
client_id: OAuth application client ID.
|
|
||||||
scopes: Space-separated list of requested scopes.
|
|
||||||
redirect_uri: URI the provider should redirect back to after authorization.
|
|
||||||
destination: URL the user should be sent to after the full OAuth flow
|
|
||||||
completes (embedded in ``state``).
|
|
||||||
state_token: CSRF token generated by :func:`oauth_generate_state_token`.
|
|
||||||
Must be stored server-side (session or signed cookie) and verified via
|
|
||||||
:func:`oauth_decode_state` on the callback endpoint (RFC 6749 §10.12).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
|
||||||
authorization page.
|
|
||||||
"""
|
|
||||||
params = urlencode(
|
|
||||||
{
|
|
||||||
"client_id": client_id,
|
|
||||||
"response_type": "code",
|
|
||||||
"scope": scopes,
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"state": oauth_encode_state(destination, state_token),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return RedirectResponse(f"{authorization_url}?{params}")
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_encode_state(url: str, state_token: str) -> str:
|
|
||||||
"""Encode a destination URL and CSRF token into an OAuth ``state`` parameter.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Post-login destination URL.
|
|
||||||
state_token: CSRF token from :func:`oauth_generate_state_token`.
|
|
||||||
"""
|
|
||||||
payload = json.dumps({"n": state_token, "d": url}, separators=(",", ":"))
|
|
||||||
return base64.urlsafe_b64encode(payload.encode()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_decode_state(
|
|
||||||
state: str | None, *, expected_state_token: str, fallback: str
|
|
||||||
) -> str:
|
|
||||||
"""Decode and CSRF-verify an OAuth ``state`` parameter.
|
|
||||||
|
|
||||||
Uses a constant-time comparison for the CSRF token to prevent timing attacks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: Raw ``state`` query parameter from the provider's callback.
|
|
||||||
expected_state_token: The token stored before the authorization redirect.
|
|
||||||
If it does not match the decoded value, ``fallback`` is returned.
|
|
||||||
fallback: URL to return when ``state`` is absent, malformed, or fails
|
|
||||||
CSRF verification.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The destination URL embedded in ``state``, or ``fallback``.
|
|
||||||
|
|
||||||
Important:
|
|
||||||
**Single-use**: delete the stored token from the session immediately
|
|
||||||
after calling this function — whether it matched or not — so that a
|
|
||||||
captured callback URL cannot be replayed.
|
|
||||||
|
|
||||||
**Open-redirect**: validate the returned URL against a known-good
|
|
||||||
origin or relative-path allowlist before issuing the final redirect.
|
|
||||||
Do not forward arbitrary URLs to ``RedirectResponse``.
|
|
||||||
"""
|
|
||||||
if not state or state == "null": # "null" guards against JS JSON.stringify(null)
|
|
||||||
return fallback
|
|
||||||
try:
|
|
||||||
padded = state + "=" * (-len(state) % 4)
|
|
||||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
|
||||||
if not isinstance(payload, dict) or not hmac.compare_digest(
|
|
||||||
payload.get("n", "").encode(), expected_state_token.encode()
|
|
||||||
):
|
|
||||||
return fallback
|
|
||||||
return str(payload["d"])
|
|
||||||
except (UnicodeDecodeError, ValueError, binascii.Error, KeyError):
|
|
||||||
return fallback
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Built-in authentication source implementations."""
|
|
||||||
|
|
||||||
from .header import APIKeyHeaderAuth
|
|
||||||
from .bearer import BearerTokenAuth
|
|
||||||
from .cookie import CookieAuth
|
|
||||||
from .multi import MultiAuth
|
|
||||||
|
|
||||||
__all__ = ["APIKeyHeaderAuth", "BearerTokenAuth", "CookieAuth", "MultiAuth"]
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""Bearer token authentication source."""
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
import secrets
|
|
||||||
from typing import Annotated, Any, Callable
|
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
|
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
from ..abc import AuthSource, _ensure_async
|
|
||||||
|
|
||||||
|
|
||||||
class BearerTokenAuth(AuthSource):
|
|
||||||
"""Bearer token authentication source.
|
|
||||||
|
|
||||||
Wraps :class:`fastapi.security.HTTPBearer` for OpenAPI documentation.
|
|
||||||
The validator is called as ``await validator(credential, **kwargs)``
|
|
||||||
where ``kwargs`` are the extra keyword arguments provided at instantiation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
validator: Sync or async callable that receives the credential and any
|
|
||||||
extra keyword arguments, and returns the authenticated identity
|
|
||||||
(e.g. a ``User`` model). Should raise
|
|
||||||
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure.
|
|
||||||
prefix: Optional token prefix (e.g. ``"user_"``). If set, only tokens
|
|
||||||
whose value starts with this prefix are matched. The prefix is
|
|
||||||
**kept** in the value passed to the validator — store and compare
|
|
||||||
tokens with their prefix included. Use :meth:`generate_token` to
|
|
||||||
create correctly-prefixed tokens. This enables multiple
|
|
||||||
``BearerTokenAuth`` instances in the same app (e.g. ``"user_"``
|
|
||||||
for user tokens, ``"org_"`` for org tokens).
|
|
||||||
**kwargs: Extra keyword arguments forwarded to the validator on every
|
|
||||||
call (e.g. ``role=Role.ADMIN``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
validator: Callable[..., Any],
|
|
||||||
*,
|
|
||||||
prefix: str | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
self._validator = _ensure_async(validator)
|
|
||||||
self._prefix = prefix
|
|
||||||
self._kwargs = kwargs
|
|
||||||
self._scheme = HTTPBearer(auto_error=False)
|
|
||||||
|
|
||||||
async def _call(
|
|
||||||
security_scopes: SecurityScopes, # noqa: ARG001
|
|
||||||
credentials: Annotated[
|
|
||||||
HTTPAuthorizationCredentials | None, Depends(self._scheme)
|
|
||||||
] = None,
|
|
||||||
) -> Any:
|
|
||||||
if credentials is None:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return await self._validate(credentials.credentials)
|
|
||||||
|
|
||||||
self._call_fn = _call
|
|
||||||
self.__signature__ = inspect.signature(_call)
|
|
||||||
|
|
||||||
async def _validate(self, token: str) -> Any:
|
|
||||||
"""Check prefix and call the validator."""
|
|
||||||
if self._prefix is not None and not token.startswith(self._prefix):
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return await self._validator(token, **self._kwargs)
|
|
||||||
|
|
||||||
async def extract(self, request: Request) -> str | None:
|
|
||||||
"""Extract the raw credential from the request without validating.
|
|
||||||
|
|
||||||
Returns ``None`` if no ``Authorization: Bearer`` header is present,
|
|
||||||
the token is empty, or the token does not match the configured prefix.
|
|
||||||
The prefix is included in the returned value.
|
|
||||||
"""
|
|
||||||
auth = request.headers.get("Authorization", "")
|
|
||||||
if not auth.startswith("Bearer "):
|
|
||||||
return None
|
|
||||||
token = auth[7:]
|
|
||||||
if not token:
|
|
||||||
return None
|
|
||||||
if self._prefix is not None and not token.startswith(self._prefix):
|
|
||||||
return None
|
|
||||||
return token
|
|
||||||
|
|
||||||
async def authenticate(self, credential: str) -> Any:
|
|
||||||
"""Validate a credential and return the identity.
|
|
||||||
|
|
||||||
Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are
|
|
||||||
the extra keyword arguments provided at instantiation.
|
|
||||||
"""
|
|
||||||
return await self._validate(credential)
|
|
||||||
|
|
||||||
def require(self, **kwargs: Any) -> "BearerTokenAuth":
|
|
||||||
"""Return a new instance with additional (or overriding) validator kwargs."""
|
|
||||||
return BearerTokenAuth(
|
|
||||||
self._validator,
|
|
||||||
prefix=self._prefix,
|
|
||||||
**{**self._kwargs, **kwargs},
|
|
||||||
)
|
|
||||||
|
|
||||||
def generate_token(self, nbytes: int = 32) -> str:
|
|
||||||
"""Generate a secure random token for this auth source.
|
|
||||||
|
|
||||||
Returns a URL-safe random token. If a prefix is configured it is
|
|
||||||
prepended — the returned value is what you store in your database
|
|
||||||
and return to the client as-is.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nbytes: Number of random bytes before base64 encoding. The
|
|
||||||
resulting string is ``ceil(nbytes * 4 / 3)`` characters
|
|
||||||
(43 chars for the default 32 bytes). Defaults to 32.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A ready-to-use token string (e.g. ``"user_Xk3..."``).
|
|
||||||
"""
|
|
||||||
token = secrets.token_urlsafe(nbytes)
|
|
||||||
if self._prefix is not None:
|
|
||||||
return f"{self._prefix}{token}"
|
|
||||||
return token
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"""Cookie-based authentication source."""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import inspect
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from typing import Annotated, Any, Callable
|
|
||||||
|
|
||||||
from fastapi import Depends, Request, Response
|
|
||||||
from fastapi.security import APIKeyCookie, SecurityScopes
|
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
from ..abc import AuthSource, _ensure_async
|
|
||||||
|
|
||||||
|
|
||||||
class CookieAuth(AuthSource):
|
|
||||||
"""Cookie-based authentication source.
|
|
||||||
|
|
||||||
Wraps :class:`fastapi.security.APIKeyCookie` for OpenAPI documentation.
|
|
||||||
Optionally signs the cookie with HMAC-SHA256 to provide stateless, tamper-
|
|
||||||
proof sessions without any database entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Cookie name.
|
|
||||||
validator: Sync or async callable that receives the cookie value
|
|
||||||
(plain, after signature verification when ``secret_key`` is set)
|
|
||||||
and any extra keyword arguments, and returns the authenticated
|
|
||||||
identity.
|
|
||||||
secret_key: When provided, the cookie is HMAC-SHA256 signed.
|
|
||||||
:meth:`set_cookie` embeds an expiry and signs the payload;
|
|
||||||
:meth:`extract` verifies the signature and expiry before handing
|
|
||||||
the plain value to the validator. When ``None`` (default), the raw
|
|
||||||
cookie value is passed to the validator as-is.
|
|
||||||
ttl: Cookie lifetime in seconds (default 24 h). Only used when
|
|
||||||
``secret_key`` is set.
|
|
||||||
secure: Set the ``Secure`` flag on the cookie so it is only transmitted
|
|
||||||
over HTTPS (default ``True``). Set to ``False`` only in local
|
|
||||||
development environments where HTTPS is unavailable.
|
|
||||||
**kwargs: Extra keyword arguments forwarded to the validator on every
|
|
||||||
call (e.g. ``role=Role.ADMIN``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
validator: Callable[..., Any],
|
|
||||||
*,
|
|
||||||
secret_key: str | None = None,
|
|
||||||
ttl: int = 86400,
|
|
||||||
secure: bool = True,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
self._name = name
|
|
||||||
self._validator = _ensure_async(validator)
|
|
||||||
self._secret_key = secret_key
|
|
||||||
self._ttl = ttl
|
|
||||||
self._secure = secure
|
|
||||||
self._kwargs = kwargs
|
|
||||||
self._scheme = APIKeyCookie(name=name, auto_error=False)
|
|
||||||
|
|
||||||
async def _call(
|
|
||||||
security_scopes: SecurityScopes, # noqa: ARG001
|
|
||||||
value: Annotated[str | None, Depends(self._scheme)] = None,
|
|
||||||
) -> Any:
|
|
||||||
if value is None:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
plain = self._verify(value)
|
|
||||||
return await self._validator(plain, **self._kwargs)
|
|
||||||
|
|
||||||
self._call_fn = _call
|
|
||||||
self.__signature__ = inspect.signature(_call)
|
|
||||||
|
|
||||||
def _hmac(self, data: str) -> str:
|
|
||||||
if self._secret_key is None:
|
|
||||||
raise RuntimeError("_hmac called without secret_key configured")
|
|
||||||
return hmac.new(
|
|
||||||
self._secret_key.encode(), data.encode(), hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
def _sign(self, value: str) -> str:
|
|
||||||
data = base64.urlsafe_b64encode(
|
|
||||||
json.dumps({"v": value, "exp": int(time.time()) + self._ttl}).encode()
|
|
||||||
).decode()
|
|
||||||
return f"{data}.{self._hmac(data)}"
|
|
||||||
|
|
||||||
def _verify(self, cookie_value: str) -> str:
|
|
||||||
"""Return the plain value, verifying HMAC + expiry when signed."""
|
|
||||||
if not self._secret_key:
|
|
||||||
return cookie_value
|
|
||||||
|
|
||||||
try:
|
|
||||||
data, sig = cookie_value.rsplit(".", 1)
|
|
||||||
except ValueError:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
|
|
||||||
if not hmac.compare_digest(self._hmac(data), sig):
|
|
||||||
raise UnauthorizedError()
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = json.loads(base64.urlsafe_b64decode(data))
|
|
||||||
value: str = payload["v"]
|
|
||||||
exp: int = payload["exp"]
|
|
||||||
except Exception:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
|
|
||||||
if exp < int(time.time()):
|
|
||||||
raise UnauthorizedError()
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
async def extract(self, request: Request) -> str | None:
|
|
||||||
return request.cookies.get(self._name)
|
|
||||||
|
|
||||||
async def authenticate(self, credential: str) -> Any:
|
|
||||||
plain = self._verify(credential)
|
|
||||||
return await self._validator(plain, **self._kwargs)
|
|
||||||
|
|
||||||
def require(self, **kwargs: Any) -> "CookieAuth":
|
|
||||||
"""Return a new instance with additional (or overriding) validator kwargs."""
|
|
||||||
return CookieAuth(
|
|
||||||
self._name,
|
|
||||||
self._validator,
|
|
||||||
secret_key=self._secret_key,
|
|
||||||
ttl=self._ttl,
|
|
||||||
secure=self._secure,
|
|
||||||
**{**self._kwargs, **kwargs},
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_cookie(self, response: Response, value: str) -> None:
|
|
||||||
"""Attach the cookie to *response*, signing it when ``secret_key`` is set."""
|
|
||||||
cookie_value = self._sign(value) if self._secret_key else value
|
|
||||||
response.set_cookie(
|
|
||||||
self._name,
|
|
||||||
cookie_value,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax",
|
|
||||||
secure=self._secure,
|
|
||||||
max_age=self._ttl,
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_cookie(self, response: Response) -> None:
|
|
||||||
"""Clear the session cookie (logout)."""
|
|
||||||
response.delete_cookie(
|
|
||||||
self._name, httponly=True, samesite="lax", secure=self._secure
|
|
||||||
)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""API key header authentication source."""
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
from typing import Annotated, Any, Callable
|
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
|
||||||
from fastapi.security import APIKeyHeader, SecurityScopes
|
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
from ..abc import AuthSource, _ensure_async
|
|
||||||
|
|
||||||
|
|
||||||
class APIKeyHeaderAuth(AuthSource):
|
|
||||||
"""API key header authentication source.
|
|
||||||
|
|
||||||
Wraps :class:`fastapi.security.APIKeyHeader` for OpenAPI documentation.
|
|
||||||
The validator is called as ``await validator(api_key, **kwargs)``
|
|
||||||
where ``kwargs`` are the extra keyword arguments provided at instantiation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: HTTP header name that carries the API key (e.g. ``"X-API-Key"``).
|
|
||||||
validator: Sync or async callable that receives the API key and any
|
|
||||||
extra keyword arguments, and returns the authenticated identity.
|
|
||||||
Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError`
|
|
||||||
on failure.
|
|
||||||
**kwargs: Extra keyword arguments forwarded to the validator on every
|
|
||||||
call (e.g. ``role=Role.ADMIN``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
validator: Callable[..., Any],
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
self._name = name
|
|
||||||
self._validator = _ensure_async(validator)
|
|
||||||
self._kwargs = kwargs
|
|
||||||
self._scheme = APIKeyHeader(name=name, auto_error=False)
|
|
||||||
|
|
||||||
async def _call(
|
|
||||||
security_scopes: SecurityScopes, # noqa: ARG001
|
|
||||||
api_key: Annotated[str | None, Depends(self._scheme)] = None,
|
|
||||||
) -> Any:
|
|
||||||
if api_key is None:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return await self._validator(api_key, **self._kwargs)
|
|
||||||
|
|
||||||
self._call_fn = _call
|
|
||||||
self.__signature__ = inspect.signature(_call)
|
|
||||||
|
|
||||||
async def extract(self, request: Request) -> str | None:
|
|
||||||
"""Extract the API key from the configured header."""
|
|
||||||
return request.headers.get(self._name) or None
|
|
||||||
|
|
||||||
async def authenticate(self, credential: str) -> Any:
|
|
||||||
"""Validate a credential and return the identity."""
|
|
||||||
return await self._validator(credential, **self._kwargs)
|
|
||||||
|
|
||||||
def require(self, **kwargs: Any) -> "APIKeyHeaderAuth":
|
|
||||||
"""Return a new instance with additional (or overriding) validator kwargs."""
|
|
||||||
return APIKeyHeaderAuth(
|
|
||||||
self._name,
|
|
||||||
self._validator,
|
|
||||||
**{**self._kwargs, **kwargs},
|
|
||||||
)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""MultiAuth: combine multiple authentication sources into a single callable."""
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.security import SecurityScopes
|
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
from ..abc import AuthSource
|
|
||||||
|
|
||||||
|
|
||||||
class MultiAuth:
|
|
||||||
"""Combine multiple authentication sources into a single callable.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
*sources: Auth source instances to try in order.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *sources: AuthSource) -> None:
|
|
||||||
self._sources = sources
|
|
||||||
|
|
||||||
async def _call(
|
|
||||||
request: Request,
|
|
||||||
security_scopes: SecurityScopes, # noqa: ARG001
|
|
||||||
**kwargs: Any, # noqa: ARG001 — absorbs scheme values injected by FastAPI
|
|
||||||
) -> Any:
|
|
||||||
for source in self._sources:
|
|
||||||
credential = await source.extract(request)
|
|
||||||
if credential is not None:
|
|
||||||
return await source.authenticate(credential)
|
|
||||||
raise UnauthorizedError()
|
|
||||||
|
|
||||||
self._call_fn = _call
|
|
||||||
|
|
||||||
# Build a merged signature that includes the security-scheme Depends()
|
|
||||||
# parameters from every source so FastAPI registers them in OpenAPI docs.
|
|
||||||
seen: set[str] = {"request", "security_scopes"}
|
|
||||||
merged: list[inspect.Parameter] = [
|
|
||||||
inspect.Parameter(
|
|
||||||
"request",
|
|
||||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
||||||
annotation=Request,
|
|
||||||
),
|
|
||||||
inspect.Parameter(
|
|
||||||
"security_scopes",
|
|
||||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
||||||
annotation=SecurityScopes,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for i, source in enumerate(sources):
|
|
||||||
for name, param in inspect.signature(source).parameters.items():
|
|
||||||
if name in seen:
|
|
||||||
continue
|
|
||||||
merged.append(param.replace(name=f"_s{i}_{name}"))
|
|
||||||
seen.add(name)
|
|
||||||
self.__signature__ = inspect.Signature(merged, return_annotation=Any)
|
|
||||||
|
|
||||||
async def __call__(self, **kwargs: Any) -> Any:
|
|
||||||
return await self._call_fn(**kwargs)
|
|
||||||
|
|
||||||
def require(self, **kwargs: Any) -> "MultiAuth":
|
|
||||||
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source."""
|
|
||||||
new_sources = tuple(
|
|
||||||
cast(Any, source).require(**kwargs)
|
|
||||||
if hasattr(source, "require")
|
|
||||||
else source
|
|
||||||
for source in self._sources
|
|
||||||
)
|
|
||||||
return MultiAuth(*new_sources)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -326,7 +326,6 @@ dependencies = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
all = [
|
all = [
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -344,10 +343,6 @@ pytest = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-xdist" },
|
{ name = "pytest-xdist" },
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -387,12 +382,10 @@ tests = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" },
|
|
||||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.100.0" },
|
{ name = "fastapi", specifier = ">=0.100.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest", "security"], marker = "extra == 'all'" },
|
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
|
||||||
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
||||||
{ name = "httpx", marker = "extra == 'security'", specifier = ">=0.25.0" },
|
|
||||||
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
||||||
@@ -400,7 +393,7 @@ requires-dist = [
|
|||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["cli", "metrics", "security", "pytest", "all"]
|
provides-extras = ["cli", "metrics", "pytest", "all"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
Reference in New Issue
Block a user