mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-06 08:34:08 +00:00
fix: multiple security bugs + remove example for now
This commit is contained in:
@@ -1 +0,0 @@
|
||||
# Authentication
|
||||
+60
-75
@@ -47,12 +47,9 @@ async def me(user: User = Security(bearer)):
|
||||
|
||||
#### 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.
|
||||
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`:
|
||||
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_..."
|
||||
@@ -63,9 +60,7 @@ 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:
|
||||
`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_")
|
||||
@@ -75,18 +70,23 @@ 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.
|
||||
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
|
||||
@@ -94,16 +94,17 @@ async def me(user: User = Security(cookie_auth)):
|
||||
|
||||
#### 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.
|
||||
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:
|
||||
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)
|
||||
@@ -119,8 +120,7 @@ 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).
|
||||
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)
|
||||
|
||||
@@ -136,14 +136,11 @@ 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"`).
|
||||
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.
|
||||
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:
|
||||
@@ -155,14 +152,11 @@ async def verify_token(token: str, *, role: Role, permission: str) -> 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=[...])`.
|
||||
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:
|
||||
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)
|
||||
@@ -191,13 +185,9 @@ multi = MultiAuth(
|
||||
|
||||
## 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.
|
||||
[`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.
|
||||
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
|
||||
@@ -211,9 +201,7 @@ async def data_route(user = Security(multi)):
|
||||
|
||||
### 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:
|
||||
`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)
|
||||
@@ -237,9 +225,7 @@ MultiAuth(
|
||||
|
||||
### 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:
|
||||
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_")
|
||||
@@ -251,8 +237,7 @@ multi = MultiAuth(user_bearer, org_bearer)
|
||||
# "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:
|
||||
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_..."
|
||||
@@ -261,9 +246,7 @@ 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()`:
|
||||
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
|
||||
@@ -284,16 +267,11 @@ 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.
|
||||
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:
|
||||
[`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
|
||||
@@ -303,42 +281,51 @@ auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
|
||||
)
|
||||
```
|
||||
|
||||
Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url`
|
||||
is `None` when the provider does not advertise one.
|
||||
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. The `destination`
|
||||
URL (where to send the user after the full flow) is encoded as the `state`
|
||||
parameter:
|
||||
[`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 `nonce` — a random CSRF token generated by [`oauth_generate_nonce()`](../reference/security.md#fastapi_toolsets.security.oauth_generate_nonce) — 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):
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.security import oauth_build_authorization_redirect
|
||||
from fastapi import Request
|
||||
from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_nonce
|
||||
|
||||
@app.get("/auth/google/login")
|
||||
async def google_login():
|
||||
async def google_login(request: Request):
|
||||
auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
|
||||
nonce = oauth_generate_nonce()
|
||||
request.session["oauth_nonce"] = nonce # 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",
|
||||
nonce=nonce,
|
||||
)
|
||||
```
|
||||
|
||||
### 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:
|
||||
[`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 nonce 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_toolsets.security import oauth_fetch_userinfo
|
||||
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(code: str, state: str):
|
||||
async def google_callback(request: Request, code: str, state: str):
|
||||
# Pop nonce first — single-use, regardless of whether verification succeeds
|
||||
nonce = request.session.pop("oauth_nonce", None)
|
||||
if nonce is None:
|
||||
raise HTTPException(status_code=400, detail="missing OAuth state")
|
||||
destination = oauth_decode_state(state, expected_nonce=nonce, 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,
|
||||
@@ -347,30 +334,28 @@ async def google_callback(code: str, state: str):
|
||||
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"])
|
||||
destination = oauth_decode_state(state, fallback="/")
|
||||
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)
|
||||
base64url-encode and decode the destination URL embedded in the OAuth `state`
|
||||
parameter. `oauth_decode_state` handles missing padding and returns the `fallback`
|
||||
if `state` is absent, `"null"`, or malformed:
|
||||
[`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 nonce embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the nonce does not match:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state
|
||||
|
||||
encoded = oauth_encode_state("/dashboard") # e.g. "L2Rhc2hib2FyZA=="
|
||||
decoded = oauth_decode_state(encoded, fallback="/") # "/dashboard"
|
||||
decoded = oauth_decode_state(None, fallback="/") # "/"
|
||||
decoded = oauth_decode_state("null", fallback="/") # "/"
|
||||
nonce = "my-random-nonce"
|
||||
encoded = oauth_encode_state("/dashboard", nonce)
|
||||
decoded = oauth_decode_state(encoded, expected_nonce=nonce, fallback="/") # "/dashboard"
|
||||
decoded = oauth_decode_state(encoded, expected_nonce="wrong", fallback="/") # "/"
|
||||
decoded = oauth_decode_state(None, expected_nonce=nonce, fallback="/") # "/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -15,6 +15,7 @@ from fastapi_toolsets.security import (
|
||||
oauth_decode_state,
|
||||
oauth_encode_state,
|
||||
oauth_fetch_userinfo,
|
||||
oauth_generate_nonce,
|
||||
oauth_resolve_provider_urls,
|
||||
)
|
||||
```
|
||||
@@ -33,6 +34,8 @@ from fastapi_toolsets.security import (
|
||||
|
||||
## ::: fastapi_toolsets.security.oauth_fetch_userinfo
|
||||
|
||||
## ::: fastapi_toolsets.security.oauth_generate_nonce
|
||||
|
||||
## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
|
||||
|
||||
## ::: fastapi_toolsets.security.oauth_encode_state
|
||||
|
||||
Reference in New Issue
Block a user