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()."""
from .base import AuthSource
from .multi import MultiAuth
from .sources import BearerTokenAuth, CookieAuth, OAuth2Auth, OpenIDAuth
from .abc import AuthSource
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
__all__ = [
"APIKeyHeaderAuth",
"AuthSource",
"BearerTokenAuth",
"CookieAuth",
"OAuth2Auth",
"OpenIDAuth",
"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."""
from .header import APIKeyHeaderAuth
from .bearer import BearerTokenAuth
from .cookie import CookieAuth
from .oauth2 import OAuth2Auth
from .openid import OpenIDAuth
from .multi import MultiAuth
__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 ..base import AuthSource
from ..abc import AuthSource, _call_validator
class BearerTokenAuth(AuthSource):
@@ -20,9 +20,9 @@ class BearerTokenAuth(AuthSource):
where ``kwargs`` are the extra keyword arguments provided at instantiation.
Args:
validator: Async callable that receives the credential and any extra
keyword arguments, and returns the authenticated identity (e.g. a
``User`` model). Should raise
validator: Sync or async callable that receives the credential and any
extra keyword arguments, and returns the authenticated identity
(e.g. a ``User`` model). Should raise
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure.
prefix: Optional token prefix (e.g. ``"user_"``). If set, only tokens
whose value starts with this prefix are matched. The prefix is
@@ -63,14 +63,11 @@ class BearerTokenAuth(AuthSource):
token = credentials.credentials
if _prefix is not None and not token.startswith(_prefix):
raise UnauthorizedError()
return await _validator(token, **_kwargs)
return await _call_validator(_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: Any) -> str | None:
"""Extract the raw credential from the request without validating.
@@ -94,7 +91,7 @@ class BearerTokenAuth(AuthSource):
Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are
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":
"""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 ..base import AuthSource
from ..abc import AuthSource, _call_validator
class CookieAuth(AuthSource):
@@ -25,9 +25,10 @@ class CookieAuth(AuthSource):
Args:
name: Cookie name.
validator: Async callable that receives the cookie value (plain, after
signature verification when ``secret_key`` is set) and any extra
keyword arguments, and returns the authenticated identity.
validator: Sync or async callable that receives the cookie value
(plain, after signature verification when ``secret_key`` is set)
and any extra keyword arguments, and returns the authenticated
identity.
secret_key: When provided, the cookie is HMAC-SHA256 signed.
:meth:`set_cookie` embeds an expiry and signs the payload;
:meth:`extract` verifies the signature and expiry before handing
@@ -66,14 +67,11 @@ class CookieAuth(AuthSource):
if value is None:
raise UnauthorizedError()
plain = _self._verify(value)
return await _self._validator(plain, **_kwargs)
return await _call_validator(_self._validator, plain, **_kwargs)
self._call_fn = _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:
assert self._secret_key is not None
return hmac.new(
@@ -116,7 +114,7 @@ class CookieAuth(AuthSource):
async def authenticate(self, credential: str) -> Any:
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":
"""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 .base import AuthSource
from ..abc import AuthSource
class MultiAuth:
@@ -60,6 +60,7 @@ class MultiAuth:
async def _call(
request: Request,
security_scopes: SecurityScopes, # noqa: ARG001
**kwargs: Any, # noqa: ARG001 — absorbs scheme values injected by FastAPI
) -> Any:
for source in _sources:
credential = await source.extract(request)
@@ -68,7 +69,29 @@ class MultiAuth:
raise UnauthorizedError()
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:
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.security import (
APIKeyHeaderAuth,
AuthSource,
BearerTokenAuth,
CookieAuth,
MultiAuth,
OAuth2Auth,
OpenIDAuth,
)
@@ -316,73 +315,85 @@ class TestCookieAuth:
assert await auth.extract(request) == "abc"
class TestOAuth2Auth:
def test_valid_token_returns_identity(self):
oauth = OAuth2Auth(token_url="/token", validator=simple_validator)
class TestAPIKeyHeaderAuth:
def test_valid_key_returns_identity(self):
auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(oauth)):
async def me(user=Security(auth)):
return user
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.json() == {"user": "alice"}
def test_missing_token_returns_401(self):
oauth = OAuth2Auth(token_url="/token", validator=simple_validator)
def test_missing_header_returns_401(self):
auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(oauth)):
async def me(user=Security(auth)):
return user
client = TestClient(_app(setup))
response = client.get("/me")
assert response.status_code == 401
@pytest.mark.anyio
async def test_extract_no_header(self):
from starlette.requests import Request
def test_invalid_key_returns_401(self):
auth = APIKeyHeaderAuth("X-API-Key", simple_validator)
auth = OAuth2Auth("/token", simple_validator)
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
request = Request(scope)
assert await auth.extract(request) is None
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(auth)):
return user
@pytest.mark.anyio
async def test_extract_empty_token(self):
from starlette.requests import Request
client = TestClient(_app(setup))
response = client.get("/me", headers={"X-API-Key": "wrong"})
assert response.status_code == 401
auth = OAuth2Auth("/token", simple_validator)
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(b"authorization", b"Bearer ")],
}
request = Request(scope)
assert await auth.extract(request) is None
def test_kwargs_forwarded_to_validator(self):
auth = APIKeyHeaderAuth("X-API-Key", role_validator, role="admin")
@pytest.mark.anyio
async def test_extract_token(self):
from starlette.requests import Request
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(auth)):
return user
auth = OAuth2Auth("/token", simple_validator)
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(b"authorization", b"Bearer mytoken")],
}
request = Request(scope)
assert await auth.extract(request) == "mytoken"
client = TestClient(_app(setup))
response = client.get("/me", headers={"X-API-Key": VALID_TOKEN})
assert response.status_code == 200
assert response.json() == {"user": "alice", "role": "admin"}
def test_require_forwards_kwargs(self):
auth = APIKeyHeaderAuth("X-API-Key", role_validator)
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):
"""OAuth2Auth.authenticate() is exercised when used inside MultiAuth."""
oauth = OAuth2Auth(token_url="/token", validator=simple_validator)
multi = MultiAuth(oauth)
"""APIKeyHeaderAuth.authenticate() is exercised inside MultiAuth."""
bearer = BearerTokenAuth(simple_validator)
api_key = APIKeyHeaderAuth("X-API-Key", simple_validator)
multi = MultiAuth(bearer, api_key)
def setup(app: FastAPI):
@app.get("/me")
@@ -390,10 +401,52 @@ class TestOAuth2Auth:
return user
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.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:
def test_first_source_matches(self):
@@ -507,7 +560,7 @@ class TestMultiAuth:
assert org_calls == ["org_acme"]
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)
multi = MultiAuth(bearer)
@@ -626,26 +679,6 @@ class TestRequire:
derived = cookie.require(scope="admin")
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):
"""require() instances work seamlessly inside MultiAuth."""
PREFIXED_TOKEN = f"user_{VALID_TOKEN}"
@@ -671,15 +704,20 @@ class TestRequire:
assert response.json() == {"user": "alice", "role": "admin"}
class TestOpenIDAuth:
DISCOVERY_URL = "https://accounts.example.com/.well-known/openid-configuration"
class TestSyncValidators:
"""Sync (non-async) validators — covers the sync path in _call_validator."""
def test_valid_token_returns_identity(self):
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_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(oidc)):
async def me(user=Security(bearer)):
return user
client = TestClient(_app(setup))
@@ -687,76 +725,16 @@ class TestOpenIDAuth:
assert response.status_code == 200
assert response.json() == {"user": "alice"}
def test_missing_token_returns_401(self):
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
def test_sync_validator_via_authenticate(self):
"""authenticate() with sync validator (MultiAuth path)."""
def setup(app: FastAPI):
@app.get("/me")
async def me(user=Security(oidc)):
return user
def sync_validator(credential: str) -> dict:
if credential != VALID_TOKEN:
raise UnauthorizedError()
return {"user": "alice"}
client = TestClient(_app(setup))
response = client.get("/me")
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)
bearer = BearerTokenAuth(sync_validator)
multi = MultiAuth(bearer)
def setup(app: FastAPI):
@app.get("/me")
@@ -768,42 +746,130 @@ class TestOpenIDAuth:
assert response.status_code == 200
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)
scope = {"type": "http", "method": "GET", "path": "/", "headers": []}
request = Request(scope)
assert await oidc.extract(request) is None
class TestCookieAuthSigned:
"""CookieAuth with HMAC-SHA256 signed cookies (secret_key path)."""
@pytest.mark.anyio
async def test_extract_empty_token(self):
from starlette.requests import Request
SECRET = "test-hmac-secret"
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(b"authorization", b"Bearer ")],
}
request = Request(scope)
assert await oidc.extract(request) is None
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
@pytest.mark.anyio
async def test_extract_token(self):
from starlette.requests import Request
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(b"authorization", b"Bearer mytoken")],
}
request = Request(scope)
assert await oidc.extract(request) == "mytoken"
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.
@@ -831,14 +897,10 @@ class TestAuthSource:
def test_builtin_classes_are_auth_sources(self):
bearer = BearerTokenAuth(simple_validator)
cookie = CookieAuth("session", cookie_validator)
oauth = OAuth2Auth("/token", simple_validator)
oidc = OpenIDAuth(
"https://example.com/.well-known/openid-configuration", simple_validator
)
api_key = APIKeyHeaderAuth("X-API-Key", simple_validator)
assert isinstance(bearer, AuthSource)
assert isinstance(cookie, AuthSource)
assert isinstance(oauth, AuthSource)
assert isinstance(oidc, AuthSource)
assert isinstance(api_key, AuthSource)
def test_custom_source_standalone_valid(self):
"""Default __call__ wires extract + authenticate via Request injection."""