Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/module/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ async def me(user: User = Security(cookie_auth)):

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.

The signing key is derived from `secret_key` *and* the cookie name, so several `CookieAuth` instances can safely share one `secret_key`: a cookie issued as `session` is never accepted as `admin_session`.

Use `set_cookie()` to issue the signed cookie on login and `delete_cookie()` to clear it on logout:

```python
Expand Down Expand Up @@ -154,6 +156,22 @@ 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=[...])`.

### Security scopes

Scopes declared on a route via `Security(auth, scopes=[...])` are never silently ignored. If the validator declares a `scopes` parameter, it receives the declared scopes (`list[str]`, empty when none are declared) and is responsible for enforcing them:

```python
async def verify_token(token: str, scopes: list[str]) -> User:
user = await decode_token(token)
if not set(scopes) <= user.scopes:
raise UnauthorizedError()
return user
```

If the validator does **not** declare a `scopes` parameter, declaring scopes on a route raises `RuntimeError` at request time — the route advertises an authorization check in OpenAPI that the auth source cannot enforce. Custom `AuthSource` subclasses get the same fail-closed behaviour by default; override `authenticate_scoped(credential, scopes)` to support scopes.

Because `scopes` is injected automatically, it is reserved: passing it as a validator kwarg at instantiation (or through `.require()`) raises `ValueError` at startup. Use a different keyword name for instance-bound requirements.

### 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:
Expand Down Expand Up @@ -274,6 +292,10 @@ 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.

The discovery URL and every endpoint it returns must use `https://` — `oauth_resolve_provider_urls()` and `oauth_fetch_userinfo()` raise `ValueError` otherwise, so a tampered discovery document cannot redirect the token exchange (and your `client_secret`) to a plaintext endpoint. Plain `http://` is accepted for loopback hosts only (`localhost`, `127.0.0.1`, `::1`), so local development IdPs keep working.

The document's `issuer` is also verified against the discovery URL ([OIDC Discovery 1.0 §4.3](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationValidation)): for a standard `<issuer>/.well-known/openid-configuration` URL, the `issuer` field must match `<issuer>` or a `ValueError` is raised. Endpoints are *not* required to share the issuer's origin — providers like Google legitimately host their token endpoint on a different domain.

### 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)):
Expand Down
54 changes: 52 additions & 2 deletions src/fastapi_toolsets/security/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,45 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any:
return wrapper


def _accepts_scopes(fn: Callable[..., Any]) -> bool:
"""Return whether *fn* declares a ``scopes`` parameter."""
try:
parameters = inspect.signature(fn).parameters
except (TypeError, ValueError):
return False
param = parameters.get("scopes")
return param is not None and param.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
)


def _reject_scopes_kwarg(kwargs: dict[str, Any]) -> None:
"""Reject ``scopes`` as a validator kwarg."""
if "scopes" in kwargs:
raise ValueError(
"'scopes' is a reserved validator kwarg: security scopes declared "
"on the route via Security(..., scopes=[...]) are injected "
"automatically. Use a different keyword name."
)


def _scope_kwargs(
owner: object, accepts_scopes: bool, scopes: list[str]
) -> dict[str, Any]:
"""Return the ``scopes`` kwarg for a validator, failing closed when unsupported."""
if accepts_scopes:
return {"scopes": scopes}
if scopes:
raise RuntimeError(
f"{type(owner).__name__} cannot enforce the security scopes "
f"{scopes!r} declared on this route: its validator does not "
"declare a 'scopes' parameter. Add one to the validator or "
"remove scopes=... from Security()."
)
return {}


class AuthSource(ABC):
"""Abstract base class for authentication sources."""

Expand All @@ -32,12 +71,12 @@ def __init__(self) -> None:

async def _call(
request: Request,
security_scopes: SecurityScopes, # noqa: ARG001
security_scopes: SecurityScopes,
) -> Any:
credential = await source.extract(request)
if credential is None:
raise UnauthorizedError()
return await source.authenticate(credential)
return await source.authenticate_scoped(credential, security_scopes.scopes)

self._call_fn: Callable[..., Any] = _call
self.__signature__ = inspect.signature(_call)
Expand All @@ -50,6 +89,17 @@ async def extract(self, request: Request) -> str | None:
async def authenticate(self, credential: str) -> Any:
"""Validate a credential and return the authenticated identity."""

async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
"""Validate a credential, enforcing the scopes declared on the route."""
if scopes:
raise RuntimeError(
f"{type(self).__name__} cannot enforce the security scopes "
f"{scopes!r} declared on this route. Override "
"authenticate_scoped() to support scopes or remove "
"scopes=... from Security()."
)
return await self.authenticate(credential)

async def __call__(self, **kwargs: Any) -> Any:
"""FastAPI dependency dispatch."""
return await self._call_fn(**kwargs)
50 changes: 44 additions & 6 deletions src/fastapi_toolsets/security/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,37 @@
import json
import secrets
from typing import Any
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit

import httpx
from async_lru import alru_cache
from fastapi.responses import RedirectResponse

_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
_DISCOVERY_SUFFIX = "/.well-known/openid-configuration"


def _validate_issuer(discovery_url: str, issuer: Any) -> None:
"""Check the discovery document claims the issuer it was fetched from."""
if not discovery_url.endswith(_DISCOVERY_SUFFIX):
return
expected = discovery_url.removesuffix(_DISCOVERY_SUFFIX).rstrip("/")
if not isinstance(issuer, str) or issuer.rstrip("/") != expected:
raise ValueError(
f"discovery document issuer {issuer!r} does not match the "
f"expected issuer {expected!r} derived from the discovery URL"
)


def _require_https(url: str, description: str) -> str:
"""Reject OAuth URLs that would send credentials over plaintext HTTP."""
parsed = urlsplit(url)
if parsed.scheme == "https":
return url
if parsed.scheme == "http" and parsed.hostname in _LOOPBACK_HOSTS:
return url
raise ValueError(f"{description} must use https:// (got {url!r})")


@alru_cache(maxsize=32)
async def oauth_resolve_provider_urls(
Expand All @@ -25,15 +50,25 @@ async def oauth_resolve_provider_urls(
Returns:
A ``(authorization_url, token_url, userinfo_url)`` tuple.
*userinfo_url* is ``None`` when the provider does not advertise one.

Raises:
ValueError: If *discovery_url* or any endpoint in the discovery
document is not HTTPS (loopback hosts excepted), or if the
document's ``issuer`` does not match the discovery URL.
"""
_require_https(discovery_url, "OIDC discovery URL")
async with httpx.AsyncClient() as client:
resp = await client.get(discovery_url)
resp.raise_for_status()
cfg = resp.json()
_validate_issuer(discovery_url, cfg.get("issuer"))
userinfo_url = cfg.get("userinfo_endpoint")
if userinfo_url is not None:
_require_https(userinfo_url, "OIDC userinfo_endpoint")
return (
cfg["authorization_endpoint"],
cfg["token_endpoint"],
cfg.get("userinfo_endpoint"),
_require_https(cfg["authorization_endpoint"], "OIDC authorization_endpoint"),
_require_https(cfg["token_endpoint"], "OIDC token_endpoint"),
userinfo_url,
)


Expand Down Expand Up @@ -64,9 +99,12 @@ async def oauth_fetch_userinfo(
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``.
ValueError: If *token_url* or *userinfo_url* is not HTTPS (loopback
hosts excepted), if the provider granted a different token type
than ``bearer``, or if it did not grant all ``required_scopes``.
"""
_require_https(token_url, "OAuth token_url")
_require_https(userinfo_url, "OAuth userinfo_url")
async with httpx.AsyncClient() as client:
token_resp = await client.post(
token_url,
Expand Down
27 changes: 20 additions & 7 deletions src/fastapi_toolsets/security/sources/bearer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@

from fastapi_toolsets.exceptions import UnauthorizedError

from ..abc import AuthSource, _ensure_async
from ..abc import (
AuthSource,
_accepts_scopes,
_ensure_async,
_reject_scopes_kwarg,
_scope_kwargs,
)


class BearerTokenAuth(AuthSource):
Expand Down Expand Up @@ -42,29 +48,32 @@ def __init__(
prefix: str | None = None,
**kwargs: Any,
) -> None:
_reject_scopes_kwarg(kwargs)
self._validator = _ensure_async(validator)
self._accepts_scopes = _accepts_scopes(validator)
self._prefix = prefix
self._kwargs = kwargs
self._scheme = HTTPBearer(auto_error=False)

async def _call(
security_scopes: SecurityScopes, # noqa: ARG001
security_scopes: SecurityScopes,
credentials: Annotated[
HTTPAuthorizationCredentials | None, Depends(self._scheme)
] = None,
) -> Any:
if credentials is None:
raise UnauthorizedError()
return await self._validate(credentials.credentials)
return await self._validate(credentials.credentials, security_scopes.scopes)

self._call_fn = _call
self.__signature__ = inspect.signature(_call)

async def _validate(self, token: str) -> Any:
"""Check prefix and call the validator."""
async def _validate(self, token: str, scopes: list[str]) -> Any:
"""Check prefix and call the validator, forwarding declared scopes."""
if self._prefix is not None and not token.startswith(self._prefix):
raise UnauthorizedError()
return await self._validator(token, **self._kwargs)
extra = _scope_kwargs(self, self._accepts_scopes, scopes)
return await self._validator(token, **extra, **self._kwargs)

async def extract(self, request: Request) -> str | None:
"""Extract the raw credential from the request without validating.
Expand All @@ -89,7 +98,11 @@ async def authenticate(self, credential: str) -> Any:
Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are
the extra keyword arguments provided at instantiation.
"""
return await self._validate(credential)
return await self._validate(credential, [])

async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
"""Validate a credential, forwarding route-declared scopes to the validator."""
return await self._validate(credential, scopes)

def require(self, **kwargs: Any) -> "BearerTokenAuth":
"""Return a new instance with additional (or overriding) validator kwargs."""
Expand Down
33 changes: 24 additions & 9 deletions src/fastapi_toolsets/security/sources/cookie.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@

from fastapi_toolsets.exceptions import UnauthorizedError

from ..abc import AuthSource, _ensure_async
from ..abc import (
AuthSource,
_accepts_scopes,
_ensure_async,
_reject_scopes_kwarg,
_scope_kwargs,
)


class CookieAuth(AuthSource):
Expand Down Expand Up @@ -53,32 +59,36 @@ def __init__(
secure: bool = True,
**kwargs: Any,
) -> None:
_reject_scopes_kwarg(kwargs)
self._name = name
self._validator = _ensure_async(validator)
self._accepts_scopes = _accepts_scopes(validator)
self._secret_key = secret_key
self._signing_key = (
hmac.new(secret_key.encode(), name.encode(), hashlib.sha256).digest()
if secret_key is not None
else None
)
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
security_scopes: SecurityScopes,
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)
return await self.authenticate_scoped(value, security_scopes.scopes)

self._call_fn = _call
self.__signature__ = inspect.signature(_call)

def _hmac(self, data: str) -> str:
if self._secret_key is None:
if self._signing_key is None:
raise RuntimeError("_hmac called without secret_key configured")
return hmac.new(
self._secret_key.encode(), data.encode(), hashlib.sha256
).hexdigest()
return hmac.new(self._signing_key, data.encode(), hashlib.sha256).hexdigest()

def _sign(self, value: str) -> str:
data = base64.urlsafe_b64encode(
Expand Down Expand Up @@ -115,8 +125,13 @@ async def extract(self, request: Request) -> str | None:
return request.cookies.get(self._name)

async def authenticate(self, credential: str) -> Any:
return await self.authenticate_scoped(credential, [])

async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
"""Verify the cookie, forwarding route-declared scopes to the validator."""
plain = self._verify(credential)
return await self._validator(plain, **self._kwargs)
extra = _scope_kwargs(self, self._accepts_scopes, scopes)
return await self._validator(plain, **extra, **self._kwargs)

def require(self, **kwargs: Any) -> "CookieAuth":
"""Return a new instance with additional (or overriding) validator kwargs."""
Expand Down
21 changes: 17 additions & 4 deletions src/fastapi_toolsets/security/sources/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@

from fastapi_toolsets.exceptions import UnauthorizedError

from ..abc import AuthSource, _ensure_async
from ..abc import (
AuthSource,
_accepts_scopes,
_ensure_async,
_reject_scopes_kwarg,
_scope_kwargs,
)


class APIKeyHeaderAuth(AuthSource):
Expand All @@ -34,18 +40,20 @@ def __init__(
validator: Callable[..., Any],
**kwargs: Any,
) -> None:
_reject_scopes_kwarg(kwargs)
self._name = name
self._validator = _ensure_async(validator)
self._accepts_scopes = _accepts_scopes(validator)
self._kwargs = kwargs
self._scheme = APIKeyHeader(name=name, auto_error=False)

async def _call(
security_scopes: SecurityScopes, # noqa: ARG001
security_scopes: SecurityScopes,
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)
return await self.authenticate_scoped(api_key, security_scopes.scopes)

self._call_fn = _call
self.__signature__ = inspect.signature(_call)
Expand All @@ -56,7 +64,12 @@ async def extract(self, request: Request) -> str | None:

async def authenticate(self, credential: str) -> Any:
"""Validate a credential and return the identity."""
return await self._validator(credential, **self._kwargs)
return await self.authenticate_scoped(credential, [])

async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
"""Validate a credential, forwarding route-declared scopes to the validator."""
extra = _scope_kwargs(self, self._accepts_scopes, scopes)
return await self._validator(credential, **extra, **self._kwargs)

def require(self, **kwargs: Any) -> "APIKeyHeaderAuth":
"""Return a new instance with additional (or overriding) validator kwargs."""
Expand Down
Loading