mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-13 19:42:59 +00:00
Compare commits
3
Commits
7a5bd73721
...
a466cde524
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a466cde524
|
||
|
|
33eeba970e
|
||
|
|
991a2b22dc
|
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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},
|
||||||
|
)
|
||||||
+25
-2
@@ -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
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user