Compare commits

...
3 Commits
Author SHA1 Message Date
d3vyce a466cde524 wip4 2026-03-06 14:41:45 -05:00
d3vyce 33eeba970e wip3 2026-03-06 14:27:04 -05:00
d3vyce 991a2b22dc wip2 2026-03-06 14:22:14 -05:00
11 changed files with 404 additions and 478 deletions
+3 -5
View File
@@ -1,14 +1,12 @@
"""Authentication helpers for FastAPI using Security().""" """Authentication helpers for FastAPI using Security()."""
from .base import AuthSource from .abc import AuthSource
from .multi import MultiAuth from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
from .sources import BearerTokenAuth, CookieAuth, OAuth2Auth, OpenIDAuth
__all__ = [ __all__ = [
"APIKeyHeaderAuth",
"AuthSource", "AuthSource",
"BearerTokenAuth", "BearerTokenAuth",
"CookieAuth", "CookieAuth",
"OAuth2Auth",
"OpenIDAuth",
"MultiAuth", "MultiAuth",
] ]
+51
View File
@@ -0,0 +1,51 @@
"""Abstract base class for authentication sources."""
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
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):
"""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)
-112
View File
@@ -1,112 +0,0 @@
"""Abstract base class for authentication sources."""
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
class AuthSource(ABC):
"""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:
"""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
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.
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
async def authenticate(self, credential: str) -> Any:
"""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:
"""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)
@@ -1,8 +1,8 @@
"""Built-in authentication source implementations.""" """Built-in authentication source implementations."""
from .header import APIKeyHeaderAuth
from .bearer import BearerTokenAuth from .bearer import BearerTokenAuth
from .cookie import CookieAuth from .cookie import CookieAuth
from .oauth2 import OAuth2Auth from .multi import MultiAuth
from .openid import OpenIDAuth
__all__ = ["BearerTokenAuth", "CookieAuth", "OAuth2Auth", "OpenIDAuth"] __all__ = ["APIKeyHeaderAuth", "BearerTokenAuth", "CookieAuth", "MultiAuth"]
@@ -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 ..abc 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 ..abc 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."""
@@ -0,0 +1,71 @@
"""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, _call_validator
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 = validator
self._kwargs = kwargs
self._scheme = APIKeyHeader(name=name, auto_error=False)
_scheme = self._scheme
_validator = validator
_kwargs = kwargs
async def _call(
security_scopes: SecurityScopes, # noqa: ARG001
api_key: Annotated[str | None, Depends(_scheme)] = None,
) -> Any:
if api_key is None:
raise UnauthorizedError()
return await _call_validator(_validator, api_key, **_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 _call_validator(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},
)
@@ -8,7 +8,7 @@ from fastapi.security import SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError from fastapi_toolsets.exceptions import UnauthorizedError
from .base import AuthSource from ..abc import AuthSource
class MultiAuth: class MultiAuth:
@@ -60,6 +60,7 @@ class MultiAuth:
async def _call( async def _call(
request: Request, request: Request,
security_scopes: SecurityScopes, # noqa: ARG001 security_scopes: SecurityScopes, # noqa: ARG001
**kwargs: Any, # noqa: ARG001 — absorbs scheme values injected by FastAPI
) -> Any: ) -> Any:
for source in _sources: for source in _sources:
credential = await source.extract(request) credential = await source.extract(request)
@@ -68,7 +69,29 @@ class MultiAuth:
raise UnauthorizedError() raise UnauthorizedError()
self._call_fn = _call self._call_fn = _call
self.__signature__ = inspect.signature(_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: async def __call__(self, **kwargs: Any) -> Any:
return await self._call_fn(**kwargs) return await self._call_fn(**kwargs)
@@ -1,75 +0,0 @@
"""OAuth2 password-bearer authentication source."""
import inspect
from typing import Annotated, Any, Callable
from fastapi import Depends, Request
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError
from ..base import AuthSource
class OAuth2Auth(AuthSource):
"""OAuth2 password-bearer authentication source.
Wraps :class:`fastapi.security.OAuth2PasswordBearer` for OpenAPI
documentation.
Args:
token_url: URL of the token endpoint (used in OpenAPI docs).
validator: Async callable that receives the token and any extra keyword
arguments, and returns the authenticated identity.
**kwargs: Extra keyword arguments forwarded to the validator on every
call.
"""
def __init__(
self,
token_url: str,
validator: Callable[..., Any],
**kwargs: Any,
) -> None:
self._token_url = token_url
self._validator = validator
self._kwargs = kwargs
self._scheme = OAuth2PasswordBearer(tokenUrl=token_url, auto_error=False)
_scheme = self._scheme
_validator = validator
_kwargs = kwargs
async def _call(
security_scopes: SecurityScopes, # noqa: ARG001
token: Annotated[str | None, Depends(_scheme)] = None,
) -> Any:
if token is None:
raise UnauthorizedError()
return await _validator(token, **_kwargs)
self._call_fn = _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:
"""Extract the bearer token from the Authorization header."""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
token = auth[7:]
return token 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) -> "OAuth2Auth":
"""Return a new instance with additional (or overriding) validator kwargs."""
return OAuth2Auth(
self._token_url,
self._validator,
**{**self._kwargs, **kwargs},
)
@@ -1,87 +0,0 @@
"""OpenID Connect authentication source."""
import inspect
from typing import Annotated, Any, Callable
from fastapi import Depends, Request
from fastapi.security import OpenIdConnect, SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError
from ..base import AuthSource
class OpenIDAuth(AuthSource):
"""OpenID Connect authentication source.
Wraps :class:`fastapi.security.OpenIdConnect` for OpenAPI documentation.
Token extraction reads the ``Authorization: Bearer <token>`` header;
validation is fully delegated to the user-supplied validator (use any
OIDC / JWT library such as ``authlib``, ``python-jose``, or ``PyJWT``).
Args:
openid_connect_url: URL of the OIDC discovery document
(``/.well-known/openid-configuration``). Used only for OpenAPI
documentation — no requests are made to this URL by this class.
validator: Async callable that receives the raw bearer token 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. ``audience="my-app"``).
"""
def __init__(
self,
openid_connect_url: str,
validator: Callable[..., Any],
**kwargs: Any,
) -> None:
self._openid_connect_url = openid_connect_url
self._validator = validator
self._kwargs = kwargs
self._scheme = OpenIdConnect(
openIdConnectUrl=openid_connect_url, auto_error=False
)
_scheme = self._scheme
_validator = validator
_kwargs = kwargs
async def _call(
security_scopes: SecurityScopes, # noqa: ARG001
authorization: Annotated[str | None, Depends(_scheme)] = None,
) -> Any:
if authorization is None:
raise UnauthorizedError()
if not authorization.startswith("Bearer "):
raise UnauthorizedError()
token = authorization[7:]
if not token:
raise UnauthorizedError()
return await _validator(token, **_kwargs)
self._call_fn = _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:
"""Extract the bearer token from the Authorization header."""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
return auth[7:] 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) -> "OpenIDAuth":
"""Return a new instance with additional (or overriding) validator kwargs."""
return OpenIDAuth(
self._openid_connect_url,
self._validator,
**{**self._kwargs, **kwargs},
)
+238 -176
View File
@@ -6,12 +6,11 @@ from fastapi.testclient import TestClient
from fastapi_toolsets.exceptions import UnauthorizedError, init_exceptions_handlers from fastapi_toolsets.exceptions import UnauthorizedError, init_exceptions_handlers
from fastapi_toolsets.security import ( from fastapi_toolsets.security import (
APIKeyHeaderAuth,
AuthSource, AuthSource,
BearerTokenAuth, BearerTokenAuth,
CookieAuth, CookieAuth,
MultiAuth, MultiAuth,
OAuth2Auth,
OpenIDAuth,
) )
@@ -316,73 +315,85 @@ class TestCookieAuth:
assert await auth.extract(request) == "abc" assert await auth.extract(request) == "abc"
class TestOAuth2Auth: class TestAPIKeyHeaderAuth:
def test_valid_token_returns_identity(self): def test_valid_key_returns_identity(self):
oauth = OAuth2Auth(token_url="/token", validator=simple_validator) auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
def setup(app: FastAPI): def setup(app: FastAPI):
@app.get("/me") @app.get("/me")
async def me(user=Security(oauth)): async def me(user=Security(auth)):
return user return user
client = TestClient(_app(setup)) client = TestClient(_app(setup))
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"}) response = client.get("/me", headers={"X-API-Key": VALID_TOKEN})
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"user": "alice"} assert response.json() == {"user": "alice"}
def test_missing_token_returns_401(self): def test_missing_header_returns_401(self):
oauth = OAuth2Auth(token_url="/token", validator=simple_validator) auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
def setup(app: FastAPI): def setup(app: FastAPI):
@app.get("/me") @app.get("/me")
async def me(user=Security(oauth)): async def me(user=Security(auth)):
return user return user
client = TestClient(_app(setup)) client = TestClient(_app(setup))
response = client.get("/me") response = client.get("/me")
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.anyio def test_invalid_key_returns_401(self):
async def test_extract_no_header(self): auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
from starlette.requests import Request
auth = OAuth2Auth("/token", simple_validator) def setup(app: FastAPI):
scope = {"type": "http", "method": "GET", "path": "/", "headers": []} @app.get("/me")
request = Request(scope) async def me(user=Security(auth)):
assert await auth.extract(request) is None return user
@pytest.mark.anyio client = TestClient(_app(setup))
async def test_extract_empty_token(self): response = client.get("/me", headers={"X-API-Key": "wrong"})
from starlette.requests import Request assert response.status_code == 401
auth = OAuth2Auth("/token", simple_validator) def test_kwargs_forwarded_to_validator(self):
scope = { auth = APIKeyHeaderAuth("X-API-Key", role_validator, role="admin")
"type": "http",
"method": "GET",
"path": "/",
"headers": [(b"authorization", b"Bearer ")],
}
request = Request(scope)
assert await auth.extract(request) is None
@pytest.mark.anyio def setup(app: FastAPI):
async def test_extract_token(self): @app.get("/me")
from starlette.requests import Request async def me(user=Security(auth)):
return user
auth = OAuth2Auth("/token", simple_validator) client = TestClient(_app(setup))
scope = { response = client.get("/me", headers={"X-API-Key": VALID_TOKEN})
"type": "http", assert response.status_code == 200
"method": "GET", assert response.json() == {"user": "alice", "role": "admin"}
"path": "/",
"headers": [(b"authorization", b"Bearer mytoken")], def test_require_forwards_kwargs(self):
} auth = APIKeyHeaderAuth("X-API-Key", role_validator)
request = Request(scope)
assert await auth.extract(request) == "mytoken" def setup(app: FastAPI):
@app.get("/admin")
async def admin(user=Security(auth.require(role="admin"))):
return user
client = TestClient(_app(setup))
response = client.get("/admin", headers={"X-API-Key": VALID_TOKEN})
assert response.status_code == 200
assert response.json() == {"user": "alice", "role": "admin"}
def test_require_preserves_name(self):
auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
derived = auth.require(role="admin")
assert derived._name == "X-API-Key"
def test_require_does_not_mutate_original(self):
auth = APIKeyHeaderAuth("X-API-Key", role_validator, role="user")
auth.require(role="admin")
assert auth._kwargs == {"role": "user"}
def test_in_multi_auth(self): def test_in_multi_auth(self):
"""OAuth2Auth.authenticate() is exercised when used inside MultiAuth.""" """APIKeyHeaderAuth.authenticate() is exercised inside MultiAuth."""
oauth = OAuth2Auth(token_url="/token", validator=simple_validator) bearer = BearerTokenAuth(simple_validator)
multi = MultiAuth(oauth) api_key = APIKeyHeaderAuth("X-API-Key", simple_validator)
multi = MultiAuth(bearer, api_key)
def setup(app: FastAPI): def setup(app: FastAPI):
@app.get("/me") @app.get("/me")
@@ -390,10 +401,52 @@ class TestOAuth2Auth:
return user return user
client = TestClient(_app(setup)) client = TestClient(_app(setup))
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"}) # No bearer → falls through to API key header
response = client.get("/me", headers={"X-API-Key": VALID_TOKEN})
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"user": "alice"} assert response.json() == {"user": "alice"}
def test_is_auth_source(self):
auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
assert isinstance(auth, AuthSource)
@pytest.mark.anyio
async def test_extract_no_header(self):
from starlette.requests import Request
auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
request = Request(scope)
assert await auth.extract(request) is None
@pytest.mark.anyio
async def test_extract_empty_header(self):
from starlette.requests import Request
auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(b"x-api-key", b"")],
}
request = Request(scope)
assert await auth.extract(request) is None
@pytest.mark.anyio
async def test_extract_key_present(self):
from starlette.requests import Request
auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(b"x-api-key", b"mykey")],
}
request = Request(scope)
assert await auth.extract(request) == "mykey"
class TestMultiAuth: class TestMultiAuth:
def test_first_source_matches(self): def test_first_source_matches(self):
@@ -507,7 +560,7 @@ class TestMultiAuth:
assert org_calls == ["org_acme"] assert org_calls == ["org_acme"]
def test_require_returns_new_multi_auth(self): def test_require_returns_new_multi_auth(self):
from fastapi_toolsets.security.multi import MultiAuth as MultiAuthClass from fastapi_toolsets.security.sources import MultiAuth as MultiAuthClass
bearer = BearerTokenAuth(role_validator) bearer = BearerTokenAuth(role_validator)
multi = MultiAuth(bearer) multi = MultiAuth(bearer)
@@ -626,26 +679,6 @@ class TestRequire:
derived = cookie.require(scope="admin") derived = cookie.require(scope="admin")
assert derived._name == "session" assert derived._name == "session"
def test_oauth2_require_forwards_kwargs(self):
oauth = OAuth2Auth("/token", role_validator)
def setup(app: FastAPI):
@app.get("/admin")
async def admin(user=Security(oauth.require(role="admin"))):
return user
client = TestClient(_app(setup))
response = client.get(
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
)
assert response.status_code == 200
assert response.json() == {"user": "alice", "role": "admin"}
def test_oauth2_require_preserves_token_url(self):
oauth = OAuth2Auth("/token", simple_validator)
derived = oauth.require(role="admin")
assert derived._token_url == "/token"
def test_bearer_require_in_multi_auth(self): def test_bearer_require_in_multi_auth(self):
"""require() instances work seamlessly inside MultiAuth.""" """require() instances work seamlessly inside MultiAuth."""
PREFIXED_TOKEN = f"user_{VALID_TOKEN}" PREFIXED_TOKEN = f"user_{VALID_TOKEN}"
@@ -671,15 +704,20 @@ class TestRequire:
assert response.json() == {"user": "alice", "role": "admin"} assert response.json() == {"user": "alice", "role": "admin"}
class TestOpenIDAuth: class TestSyncValidators:
DISCOVERY_URL = "https://accounts.example.com/.well-known/openid-configuration" """Sync (non-async) validators — covers the sync path in _call_validator."""
def test_valid_token_returns_identity(self): def test_bearer_sync_validator(self):
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator) def sync_validator(credential: str) -> dict:
if credential != VALID_TOKEN:
raise UnauthorizedError()
return {"user": "alice"}
bearer = BearerTokenAuth(sync_validator)
def setup(app: FastAPI): def setup(app: FastAPI):
@app.get("/me") @app.get("/me")
async def me(user=Security(oidc)): async def me(user=Security(bearer)):
return user return user
client = TestClient(_app(setup)) client = TestClient(_app(setup))
@@ -687,76 +725,16 @@ class TestOpenIDAuth:
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"user": "alice"} assert response.json() == {"user": "alice"}
def test_missing_token_returns_401(self): def test_sync_validator_via_authenticate(self):
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator) """authenticate() with sync validator (MultiAuth path)."""
def setup(app: FastAPI): def sync_validator(credential: str) -> dict:
@app.get("/me") if credential != VALID_TOKEN:
async def me(user=Security(oidc)): raise UnauthorizedError()
return user return {"user": "alice"}
client = TestClient(_app(setup)) bearer = BearerTokenAuth(sync_validator)
response = client.get("/me") multi = MultiAuth(bearer)
assert response.status_code == 401
def test_invalid_token_returns_401(self):
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 wrong"})
assert response.status_code == 401
def test_kwargs_forwarded_to_validator(self):
oidc = OpenIDAuth(self.DISCOVERY_URL, role_validator, role="admin")
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": f"Bearer {VALID_TOKEN}"})
assert response.status_code == 200
assert response.json() == {"user": "alice", "role": "admin"}
def test_require_forwards_kwargs(self):
oidc = OpenIDAuth(self.DISCOVERY_URL, role_validator)
def setup(app: FastAPI):
@app.get("/admin")
async def admin(user=Security(oidc.require(role="admin"))):
return user
client = TestClient(_app(setup))
response = client.get(
"/admin", headers={"Authorization": f"Bearer {VALID_TOKEN}"}
)
assert response.status_code == 200
assert response.json() == {"user": "alice", "role": "admin"}
def test_require_preserves_discovery_url(self):
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
derived = oidc.require(role="admin")
assert derived._openid_connect_url == self.DISCOVERY_URL
def test_require_does_not_mutate_original(self):
oidc = OpenIDAuth(self.DISCOVERY_URL, role_validator, role="user")
oidc.require(role="admin")
assert oidc._kwargs == {"role": "user"}
def test_is_auth_source(self):
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
assert isinstance(oidc, AuthSource)
def test_in_multi_auth(self):
"""OpenIDAuth works seamlessly inside MultiAuth."""
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
multi = MultiAuth(oidc)
def setup(app: FastAPI): def setup(app: FastAPI):
@app.get("/me") @app.get("/me")
@@ -768,42 +746,130 @@ class TestOpenIDAuth:
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"user": "alice"} assert response.json() == {"user": "alice"}
@pytest.mark.anyio
async def test_extract_no_header(self):
from starlette.requests import Request
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator) class TestCookieAuthSigned:
scope = {"type": "http", "method": "GET", "path": "/", "headers": []} """CookieAuth with HMAC-SHA256 signed cookies (secret_key path)."""
request = Request(scope)
assert await oidc.extract(request) is None
@pytest.mark.anyio SECRET = "test-hmac-secret"
async def test_extract_empty_token(self):
from starlette.requests import Request
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator) def test_valid_signed_cookie_via_set_cookie(self):
scope = { """set_cookie signs the value; the signed cookie is verified on read."""
"type": "http", from fastapi import Response
"method": "GET",
"path": "/",
"headers": [(b"authorization", b"Bearer ")],
}
request = Request(scope)
assert await oidc.extract(request) is None
@pytest.mark.anyio auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
async def test_extract_token(self):
from starlette.requests import Request
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator) def setup(app: FastAPI):
scope = { @app.get("/login")
"type": "http", async def login(response: Response):
"method": "GET", auth.set_cookie(response, VALID_COOKIE)
"path": "/", return {"ok": True}
"headers": [(b"authorization", b"Bearer mytoken")],
} @app.get("/me")
request = Request(scope) async def me(user=Security(auth)):
assert await oidc.extract(request) == "mytoken" 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.
@@ -831,14 +897,10 @@ class TestAuthSource:
def test_builtin_classes_are_auth_sources(self): def test_builtin_classes_are_auth_sources(self):
bearer = BearerTokenAuth(simple_validator) bearer = BearerTokenAuth(simple_validator)
cookie = CookieAuth("session", cookie_validator) cookie = CookieAuth("session", cookie_validator)
oauth = OAuth2Auth("/token", simple_validator) api_key = APIKeyHeaderAuth("X-API-Key", simple_validator)
oidc = OpenIDAuth(
"https://example.com/.well-known/openid-configuration", simple_validator
)
assert isinstance(bearer, AuthSource) assert isinstance(bearer, AuthSource)
assert isinstance(cookie, AuthSource) assert isinstance(cookie, AuthSource)
assert isinstance(oauth, AuthSource) assert isinstance(api_key, AuthSource)
assert isinstance(oidc, AuthSource)
def test_custom_source_standalone_valid(self): def test_custom_source_standalone_valid(self):
"""Default __call__ wires extract + authenticate via Request injection.""" """Default __call__ wires extract + authenticate via Request injection."""