This commit is contained in:
2026-03-06 14:22:14 -05:00
parent 7a5bd73721
commit 991a2b22dc
6 changed files with 237 additions and 111 deletions
+14 -75
View File
@@ -10,66 +10,20 @@ from fastapi.security import SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError from fastapi_toolsets.exceptions import UnauthorizedError
async def _call_validator(
validator: Callable[..., Any], *args: Any, **kwargs: Any
) -> Any:
"""Call *validator* with *args* and *kwargs*, awaiting it if it is a coroutine function."""
if inspect.iscoroutinefunction(validator):
return await validator(*args, **kwargs)
return validator(*args, **kwargs)
class AuthSource(ABC): class AuthSource(ABC):
"""Abstract base class for authentication sources. """Abstract base class for authentication sources."""
Subclass this to create a custom auth source that works with
:func:`~fastapi_toolsets.security.MultiAuth` and can be used directly
with :func:`fastapi.Security`.
Concrete subclasses must implement :meth:`extract` and
:meth:`authenticate`. The default :meth:`__call__` (set up in
:meth:`__init__`) wires them together for FastAPI dependency injection.
Custom subclasses with their own ``__init__`` **must** call
``super().__init__()`` to activate the default dependency behaviour::
class JWTAuth(AuthSource):
def __init__(self, secret: str, *, role: str | None = None):
super().__init__() # required
self._secret = secret
self._role = role
async def extract(self, request: Request) -> str | None:
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
return auth[7:] or None
async def authenticate(self, credential: str) -> User:
payload = jwt.decode(credential, self._secret)
if self._role and payload.get("role") != self._role:
raise UnauthorizedError()
return User(**payload)
jwt_auth = JWTAuth(secret="mysecret")
@app.get("/me")
async def me(user: User = Security(jwt_auth)):
return user
# Works with MultiAuth too
multi = MultiAuth(jwt_auth, CookieAuth("session", verify_session))
.. note::
The default ``__call__`` does not register a security scheme in the
OpenAPI spec. Built-in sources (``BearerTokenAuth`` etc.) override
``__call__`` using the ``__signature__`` trick to provide a FastAPI
security scheme for Swagger UI.
"""
def __init__(self) -> None: def __init__(self) -> None:
"""Set up the default FastAPI dependency signature. """Set up the default FastAPI dependency signature."""
Creates a closure that FastAPI can introspect to inject
:class:`fastapi.Request` and :class:`fastapi.security.SecurityScopes`.
The :meth:`__signature__` attribute is set so that ``inspect.signature``
(which FastAPI uses internally) returns the correct parameter list.
Subclasses with their own ``__init__`` must call ``super().__init__()``.
Built-in subclasses (``BearerTokenAuth`` etc.) skip this and set up
their own ``_call_fn`` / ``__signature__`` directly.
"""
source = self source = self
async def _call( async def _call(
@@ -86,27 +40,12 @@ class AuthSource(ABC):
@abstractmethod @abstractmethod
async def extract(self, request: Request) -> str | None: async def extract(self, request: Request) -> str | None:
"""Extract the raw credential from the request without validating. """Extract the raw credential from the request without validating."""
Returns ``None`` if no credential is present for this source.
This method must be fast and free of I/O — it is called by
:func:`~fastapi_toolsets.security.MultiAuth` for every source on
every request.
"""
@abstractmethod @abstractmethod
async def authenticate(self, credential: str) -> Any: async def authenticate(self, credential: str) -> Any:
"""Validate a credential and return the authenticated identity. """Validate a credential and return the authenticated identity."""
Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError`
(or any exception) when the credential is invalid. The return value
is injected into the route handler as the dependency value.
"""
async def __call__(self, **kwargs: Any) -> Any: async def __call__(self, **kwargs: Any) -> Any:
"""FastAPI dependency dispatch. """FastAPI dependency dispatch."""
Delegates to the closure stored in ``_call_fn``, whose signature
(stored in ``__signature__``) tells FastAPI which parameters to inject.
"""
return await self._call_fn(**kwargs) return await self._call_fn(**kwargs)
@@ -9,7 +9,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityS
from fastapi_toolsets.exceptions import UnauthorizedError from fastapi_toolsets.exceptions import UnauthorizedError
from ..base import AuthSource from ..base import AuthSource, _call_validator
class BearerTokenAuth(AuthSource): class BearerTokenAuth(AuthSource):
@@ -20,9 +20,9 @@ class BearerTokenAuth(AuthSource):
where ``kwargs`` are the extra keyword arguments provided at instantiation. where ``kwargs`` are the extra keyword arguments provided at instantiation.
Args: Args:
validator: Async callable that receives the credential and any extra validator: Sync or async callable that receives the credential and any
keyword arguments, and returns the authenticated identity (e.g. a extra keyword arguments, and returns the authenticated identity
``User`` model). Should raise (e.g. a ``User`` model). Should raise
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure. :class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure.
prefix: Optional token prefix (e.g. ``"user_"``). If set, only tokens prefix: Optional token prefix (e.g. ``"user_"``). If set, only tokens
whose value starts with this prefix are matched. The prefix is whose value starts with this prefix are matched. The prefix is
@@ -63,14 +63,11 @@ class BearerTokenAuth(AuthSource):
token = credentials.credentials token = credentials.credentials
if _prefix is not None and not token.startswith(_prefix): if _prefix is not None and not token.startswith(_prefix):
raise UnauthorizedError() raise UnauthorizedError()
return await _validator(token, **_kwargs) return await _call_validator(_validator, token, **_kwargs)
self._call_fn = _call self._call_fn = _call
self.__signature__ = inspect.signature(_call) self.__signature__ = inspect.signature(_call)
async def __call__(self, **kwargs: Any) -> Any:
return await self._call_fn(**kwargs)
async def extract(self, request: Any) -> str | None: async def extract(self, request: Any) -> str | None:
"""Extract the raw credential from the request without validating. """Extract the raw credential from the request without validating.
@@ -94,7 +91,7 @@ class BearerTokenAuth(AuthSource):
Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are
the extra keyword arguments provided at instantiation. the extra keyword arguments provided at instantiation.
""" """
return await self._validator(credential, **self._kwargs) return await _call_validator(self._validator, credential, **self._kwargs)
def require(self, **kwargs: Any) -> "BearerTokenAuth": def require(self, **kwargs: Any) -> "BearerTokenAuth":
"""Return a new instance with additional (or overriding) validator kwargs.""" """Return a new instance with additional (or overriding) validator kwargs."""
@@ -13,7 +13,7 @@ from fastapi.security import APIKeyCookie, SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError from fastapi_toolsets.exceptions import UnauthorizedError
from ..base import AuthSource from ..base import AuthSource, _call_validator
class CookieAuth(AuthSource): class CookieAuth(AuthSource):
@@ -25,9 +25,10 @@ class CookieAuth(AuthSource):
Args: Args:
name: Cookie name. name: Cookie name.
validator: Async callable that receives the cookie value (plain, after validator: Sync or async callable that receives the cookie value
signature verification when ``secret_key`` is set) and any extra (plain, after signature verification when ``secret_key`` is set)
keyword arguments, and returns the authenticated identity. and any extra keyword arguments, and returns the authenticated
identity.
secret_key: When provided, the cookie is HMAC-SHA256 signed. secret_key: When provided, the cookie is HMAC-SHA256 signed.
:meth:`set_cookie` embeds an expiry and signs the payload; :meth:`set_cookie` embeds an expiry and signs the payload;
:meth:`extract` verifies the signature and expiry before handing :meth:`extract` verifies the signature and expiry before handing
@@ -66,14 +67,11 @@ class CookieAuth(AuthSource):
if value is None: if value is None:
raise UnauthorizedError() raise UnauthorizedError()
plain = _self._verify(value) plain = _self._verify(value)
return await _self._validator(plain, **_kwargs) return await _call_validator(_self._validator, plain, **_kwargs)
self._call_fn = _call self._call_fn = _call
self.__signature__ = inspect.signature(_call) self.__signature__ = inspect.signature(_call)
async def __call__(self, **kwargs: Any) -> Any:
return await self._call_fn(**kwargs)
def _hmac(self, data: str) -> str: def _hmac(self, data: str) -> str:
assert self._secret_key is not None assert self._secret_key is not None
return hmac.new( return hmac.new(
@@ -116,7 +114,7 @@ class CookieAuth(AuthSource):
async def authenticate(self, credential: str) -> Any: async def authenticate(self, credential: str) -> Any:
plain = self._verify(credential) plain = self._verify(credential)
return await self._validator(plain, **self._kwargs) return await _call_validator(self._validator, plain, **self._kwargs)
def require(self, **kwargs: Any) -> "CookieAuth": def require(self, **kwargs: Any) -> "CookieAuth":
"""Return a new instance with additional (or overriding) validator kwargs.""" """Return a new instance with additional (or overriding) validator kwargs."""
@@ -8,7 +8,7 @@ from fastapi.security import OAuth2PasswordBearer, SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError from fastapi_toolsets.exceptions import UnauthorizedError
from ..base import AuthSource from ..base import AuthSource, _call_validator
class OAuth2Auth(AuthSource): class OAuth2Auth(AuthSource):
@@ -19,8 +19,8 @@ class OAuth2Auth(AuthSource):
Args: Args:
token_url: URL of the token endpoint (used in OpenAPI docs). token_url: URL of the token endpoint (used in OpenAPI docs).
validator: Async callable that receives the token and any extra keyword validator: Sync or async callable that receives the token and any extra
arguments, and returns the authenticated identity. keyword arguments, and returns the authenticated identity.
**kwargs: Extra keyword arguments forwarded to the validator on every **kwargs: Extra keyword arguments forwarded to the validator on every
call. call.
""" """
@@ -46,14 +46,11 @@ class OAuth2Auth(AuthSource):
) -> Any: ) -> Any:
if token is None: if token is None:
raise UnauthorizedError() raise UnauthorizedError()
return await _validator(token, **_kwargs) return await _call_validator(_validator, token, **_kwargs)
self._call_fn = _call self._call_fn = _call
self.__signature__ = inspect.signature(_call) self.__signature__ = inspect.signature(_call)
async def __call__(self, **kwargs: Any) -> Any:
return await self._call_fn(**kwargs)
async def extract(self, request: Request) -> str | None: async def extract(self, request: Request) -> str | None:
"""Extract the bearer token from the Authorization header.""" """Extract the bearer token from the Authorization header."""
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
@@ -64,7 +61,7 @@ class OAuth2Auth(AuthSource):
async def authenticate(self, credential: str) -> Any: async def authenticate(self, credential: str) -> Any:
"""Validate a credential and return the identity.""" """Validate a credential and return the identity."""
return await self._validator(credential, **self._kwargs) return await _call_validator(self._validator, credential, **self._kwargs)
def require(self, **kwargs: Any) -> "OAuth2Auth": def require(self, **kwargs: Any) -> "OAuth2Auth":
"""Return a new instance with additional (or overriding) validator kwargs.""" """Return a new instance with additional (or overriding) validator kwargs."""
@@ -8,7 +8,7 @@ from fastapi.security import OpenIdConnect, SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError from fastapi_toolsets.exceptions import UnauthorizedError
from ..base import AuthSource from ..base import AuthSource, _call_validator
class OpenIDAuth(AuthSource): class OpenIDAuth(AuthSource):
@@ -23,10 +23,10 @@ class OpenIDAuth(AuthSource):
openid_connect_url: URL of the OIDC discovery document openid_connect_url: URL of the OIDC discovery document
(``/.well-known/openid-configuration``). Used only for OpenAPI (``/.well-known/openid-configuration``). Used only for OpenAPI
documentation — no requests are made to this URL by this class. documentation — no requests are made to this URL by this class.
validator: Async callable that receives the raw bearer token and any validator: Sync or async callable that receives the raw bearer token
extra keyword arguments, and returns the authenticated identity. and any extra keyword arguments, and returns the authenticated
Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError` identity. Should raise
on failure. :class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure.
**kwargs: Extra keyword arguments forwarded to the validator on every **kwargs: Extra keyword arguments forwarded to the validator on every
call (e.g. ``audience="my-app"``). call (e.g. ``audience="my-app"``).
""" """
@@ -59,14 +59,11 @@ class OpenIDAuth(AuthSource):
token = authorization[7:] token = authorization[7:]
if not token: if not token:
raise UnauthorizedError() raise UnauthorizedError()
return await _validator(token, **_kwargs) return await _call_validator(_validator, token, **_kwargs)
self._call_fn = _call self._call_fn = _call
self.__signature__ = inspect.signature(_call) self.__signature__ = inspect.signature(_call)
async def __call__(self, **kwargs: Any) -> Any:
return await self._call_fn(**kwargs)
async def extract(self, request: Request) -> str | None: async def extract(self, request: Request) -> str | None:
"""Extract the bearer token from the Authorization header.""" """Extract the bearer token from the Authorization header."""
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
@@ -76,7 +73,7 @@ class OpenIDAuth(AuthSource):
async def authenticate(self, credential: str) -> Any: async def authenticate(self, credential: str) -> Any:
"""Validate a credential and return the identity.""" """Validate a credential and return the identity."""
return await self._validator(credential, **self._kwargs) return await _call_validator(self._validator, credential, **self._kwargs)
def require(self, **kwargs: Any) -> "OpenIDAuth": def require(self, **kwargs: Any) -> "OpenIDAuth":
"""Return a new instance with additional (or overriding) validator kwargs.""" """Return a new instance with additional (or overriding) validator kwargs."""
+198
View File
@@ -806,6 +806,204 @@ class TestOpenIDAuth:
assert await oidc.extract(request) == "mytoken" assert await oidc.extract(request) == "mytoken"
class TestSyncValidators:
"""Sync (non-async) validators — covers the sync path in _call_validator."""
def test_bearer_sync_validator(self):
def sync_validator(credential: str) -> dict:
if credential != VALID_TOKEN:
raise UnauthorizedError()
return {"user": "alice"}
bearer = BearerTokenAuth(sync_validator)
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(bearer)):
return user
client = TestClient(_app(setup))
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
assert response.status_code == 200
assert response.json() == {"user": "alice"}
def test_sync_validator_via_authenticate(self):
"""authenticate() with sync validator (MultiAuth path)."""
def sync_validator(credential: str) -> dict:
if credential != VALID_TOKEN:
raise UnauthorizedError()
return {"user": "alice"}
bearer = BearerTokenAuth(sync_validator)
multi = MultiAuth(bearer)
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(multi)):
return user
client = TestClient(_app(setup))
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
assert response.status_code == 200
assert response.json() == {"user": "alice"}
class TestOpenIDAuthEdgeCases:
DISCOVERY_URL = "https://accounts.example.com/.well-known/openid-configuration"
def test_non_bearer_authorization_returns_401(self):
"""Authorization header present but not Bearer scheme."""
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(oidc)):
return user
client = TestClient(_app(setup))
response = client.get("/me", headers={"Authorization": "Basic dXNlcjpwYXNz"})
assert response.status_code == 401
def test_empty_bearer_token_returns_401(self):
"""Authorization: Bearer with no token after the scheme prefix."""
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(oidc)):
return user
client = TestClient(_app(setup))
response = client.get("/me", headers={"Authorization": "Bearer "})
assert response.status_code == 401
class TestCookieAuthSigned:
"""CookieAuth with HMAC-SHA256 signed cookies (secret_key path)."""
SECRET = "test-hmac-secret"
def test_valid_signed_cookie_via_set_cookie(self):
"""set_cookie signs the value; the signed cookie is verified on read."""
from fastapi import Response
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
def setup(app: FastAPI):
@app.get("/login")
async def login(response: Response):
auth.set_cookie(response, VALID_COOKIE)
return {"ok": True}
@app.get("/me")
async def me(user=Security(auth)):
return user
with TestClient(_app(setup)) as client:
client.get("/login")
response = client.get("/me")
assert response.status_code == 200
assert response.json() == {"session": VALID_COOKIE}
def test_tampered_signature_returns_401(self):
"""A cookie whose HMAC signature has been modified is rejected."""
import base64 as _b64
import json as _json
import time as _time
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(auth)):
return user
client = TestClient(_app(setup))
data = _b64.urlsafe_b64encode(
_json.dumps({"v": VALID_COOKIE, "exp": int(_time.time()) + 9999}).encode()
).decode()
response = client.get("/me", cookies={"session": f"{data}.invalidsig"})
assert response.status_code == 401
def test_expired_signed_cookie_returns_401(self):
"""A signed cookie past its expiry timestamp is rejected."""
import base64 as _b64
import hashlib as _hashlib
import hmac as _hmac
import json as _json
import time as _time
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(auth)):
return user
client = TestClient(_app(setup))
data = _b64.urlsafe_b64encode(
_json.dumps({"v": VALID_COOKIE, "exp": int(_time.time()) - 1}).encode()
).decode()
sig = _hmac.new(
self.SECRET.encode(), data.encode(), _hashlib.sha256
).hexdigest()
response = client.get("/me", cookies={"session": f"{data}.{sig}"})
assert response.status_code == 401
def test_invalid_json_payload_returns_401(self):
"""A signed cookie whose payload is not valid JSON is rejected."""
import base64 as _b64
import hashlib as _hashlib
import hmac as _hmac
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(auth)):
return user
client = TestClient(_app(setup))
data = _b64.urlsafe_b64encode(b"not-valid-json").decode()
sig = _hmac.new(
self.SECRET.encode(), data.encode(), _hashlib.sha256
).hexdigest()
response = client.get("/me", cookies={"session": f"{data}.{sig}"})
assert response.status_code == 401
def test_malformed_cookie_no_dot_returns_401(self):
"""A signed cookie without the dot separator is rejected."""
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(auth)):
return user
client = TestClient(_app(setup))
response = client.get("/me", cookies={"session": "nodothere"})
assert response.status_code == 401
def test_set_cookie_without_secret(self):
"""set_cookie without secret_key writes the raw value."""
from starlette.responses import Response as StarletteResponse
auth = CookieAuth("session", cookie_validator)
response = StarletteResponse()
auth.set_cookie(response, "rawvalue")
assert "session=rawvalue" in response.headers["set-cookie"]
def test_delete_cookie(self):
"""delete_cookie produces a Set-Cookie header that clears the session."""
from starlette.responses import Response as StarletteResponse
auth = CookieAuth("session", cookie_validator)
response = StarletteResponse()
auth.delete_cookie(response)
assert "session" in response.headers["set-cookie"]
# Minimal concrete subclass used only in tests below. # Minimal concrete subclass used only in tests below.
class _HeaderAuth(AuthSource): class _HeaderAuth(AuthSource):
"""Reads a custom X-Token header — no FastAPI security scheme.""" """Reads a custom X-Token header — no FastAPI security scheme."""