mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-13 19:42:59 +00:00
Compare commits
2
Commits
a466cde524
...
6fa135ef2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fa135ef2a
|
||
|
|
aa9a419e01
|
@@ -1,13 +1,24 @@
|
||||
"""Authentication helpers for FastAPI using Security()."""
|
||||
|
||||
from .abc import AuthSource
|
||||
from .sources import BearerTokenAuth, CookieAuth, MultiAuth, OAuth2Auth, OpenIDAuth
|
||||
from .oauth import (
|
||||
build_authorization_redirect,
|
||||
decode_oauth_state,
|
||||
encode_oauth_state,
|
||||
fetch_userinfo,
|
||||
resolve_provider_urls,
|
||||
)
|
||||
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
|
||||
|
||||
__all__ = [
|
||||
"APIKeyHeaderAuth",
|
||||
"AuthSource",
|
||||
"BearerTokenAuth",
|
||||
"CookieAuth",
|
||||
"MultiAuth",
|
||||
"OAuth2Auth",
|
||||
"OpenIDAuth",
|
||||
"build_authorization_redirect",
|
||||
"decode_oauth_state",
|
||||
"fetch_userinfo",
|
||||
"encode_oauth_state",
|
||||
"resolve_provider_urls",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""OAuth 2.0 / OIDC helper utilities."""
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
_discovery_cache: dict[str, dict] = {}
|
||||
|
||||
|
||||
async def resolve_provider_urls(discovery_url: str) -> tuple[str, str, str | None]:
|
||||
"""Fetch the OIDC discovery document and return endpoint URLs.
|
||||
|
||||
Args:
|
||||
discovery_url: URL of the provider's ``/.well-known/openid-configuration``.
|
||||
|
||||
Returns:
|
||||
A ``(authorization_url, token_url, userinfo_url)`` tuple.
|
||||
*userinfo_url* is ``None`` when the provider does not advertise one.
|
||||
"""
|
||||
if discovery_url not in _discovery_cache:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(discovery_url)
|
||||
resp.raise_for_status()
|
||||
_discovery_cache[discovery_url] = resp.json()
|
||||
cfg = _discovery_cache[discovery_url]
|
||||
return (
|
||||
cfg["authorization_endpoint"],
|
||||
cfg["token_endpoint"],
|
||||
cfg.get("userinfo_endpoint"),
|
||||
)
|
||||
|
||||
|
||||
async def fetch_userinfo(
|
||||
*,
|
||||
token_url: str,
|
||||
userinfo_url: str,
|
||||
code: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
redirect_uri: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Exchange an authorization code for tokens and return the userinfo payload.
|
||||
|
||||
Performs the two-step OAuth 2.0 / OIDC token exchange:
|
||||
|
||||
1. POSTs the authorization *code* to *token_url* to obtain an access token.
|
||||
2. GETs *userinfo_url* using that access token as a Bearer credential.
|
||||
|
||||
Args:
|
||||
token_url: Provider's token endpoint.
|
||||
userinfo_url: Provider's userinfo endpoint.
|
||||
code: Authorization code received from the provider's callback.
|
||||
client_id: OAuth application client ID.
|
||||
client_secret: OAuth application client secret.
|
||||
redirect_uri: Redirect URI that was used in the authorization request.
|
||||
|
||||
Returns:
|
||||
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_resp = await client.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
access_token = token_resp.json()["access_token"]
|
||||
|
||||
userinfo_resp = await client.get(
|
||||
userinfo_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
userinfo_resp.raise_for_status()
|
||||
return userinfo_resp.json()
|
||||
|
||||
|
||||
def build_authorization_redirect(
|
||||
authorization_url: str,
|
||||
*,
|
||||
client_id: str,
|
||||
scopes: str,
|
||||
redirect_uri: str,
|
||||
destination: str,
|
||||
) -> RedirectResponse:
|
||||
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
|
||||
|
||||
Args:
|
||||
authorization_url: Provider's authorization endpoint.
|
||||
client_id: OAuth application client ID.
|
||||
scopes: Space-separated list of requested scopes.
|
||||
redirect_uri: URI the provider should redirect back to after authorization.
|
||||
destination: URL the user should be sent to after the full OAuth flow
|
||||
completes (encoded as ``state``).
|
||||
|
||||
Returns:
|
||||
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
||||
authorization page.
|
||||
"""
|
||||
params = urlencode(
|
||||
{
|
||||
"client_id": client_id,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"redirect_uri": redirect_uri,
|
||||
"state": encode_oauth_state(destination),
|
||||
}
|
||||
)
|
||||
return RedirectResponse(f"{authorization_url}?{params}")
|
||||
|
||||
|
||||
def encode_oauth_state(url: str) -> str:
|
||||
"""Base64url-encode a URL to embed as an OAuth ``state`` parameter."""
|
||||
return base64.urlsafe_b64encode(url.encode()).decode()
|
||||
|
||||
|
||||
def decode_oauth_state(state: str | None, *, fallback: str) -> str:
|
||||
"""Decode a base64url OAuth ``state`` parameter.
|
||||
|
||||
Handles missing padding (some providers strip ``=``).
|
||||
Returns *fallback* if *state* is absent, the literal string ``"null"``,
|
||||
or cannot be decoded.
|
||||
"""
|
||||
if not state or state == "null":
|
||||
return fallback
|
||||
try:
|
||||
padded = state + "=" * (4 - len(state) % 4)
|
||||
return base64.urlsafe_b64decode(padded).decode()
|
||||
except Exception:
|
||||
return fallback
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Built-in authentication source implementations."""
|
||||
|
||||
from .header import APIKeyHeaderAuth
|
||||
from .bearer import BearerTokenAuth
|
||||
from .cookie import CookieAuth
|
||||
from .multi import MultiAuth
|
||||
from .oauth2 import OAuth2Auth
|
||||
from .openid import OpenIDAuth
|
||||
|
||||
__all__ = ["BearerTokenAuth", "CookieAuth", "MultiAuth", "OAuth2Auth", "OpenIDAuth"]
|
||||
__all__ = ["APIKeyHeaderAuth", "BearerTokenAuth", "CookieAuth", "MultiAuth"]
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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,72 +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 ..abc import AuthSource, _call_validator
|
||||
|
||||
|
||||
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: Sync or 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 _call_validator(_validator, token, **_kwargs)
|
||||
|
||||
self._call_fn = _call
|
||||
self.__signature__ = inspect.signature(_call)
|
||||
|
||||
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 _call_validator(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,84 +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 ..abc import AuthSource, _call_validator
|
||||
|
||||
|
||||
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: Sync or 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 _call_validator(_validator, token, **_kwargs)
|
||||
|
||||
self._call_fn = _call
|
||||
self.__signature__ = inspect.signature(_call)
|
||||
|
||||
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 _call_validator(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},
|
||||
)
|
||||
+319
-236
@@ -1,17 +1,24 @@
|
||||
"""Tests for fastapi_toolsets.security."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Security
|
||||
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,
|
||||
build_authorization_redirect,
|
||||
decode_oauth_state,
|
||||
encode_oauth_state,
|
||||
fetch_userinfo,
|
||||
resolve_provider_urls,
|
||||
)
|
||||
|
||||
|
||||
@@ -316,73 +323,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 +409,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):
|
||||
@@ -626,26 +687,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,141 +712,6 @@ class TestRequire:
|
||||
assert response.json() == {"user": "alice", "role": "admin"}
|
||||
|
||||
|
||||
class TestOpenIDAuth:
|
||||
DISCOVERY_URL = "https://accounts.example.com/.well-known/openid-configuration"
|
||||
|
||||
def test_valid_token_returns_identity(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": f"Bearer {VALID_TOKEN}"})
|
||||
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 setup(app: FastAPI):
|
||||
@app.get("/me")
|
||||
async def me(user=Security(oidc)):
|
||||
return user
|
||||
|
||||
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)
|
||||
|
||||
def setup(app: FastAPI):
|
||||
@app.get("/me")
|
||||
async def me(user=Security(multi)):
|
||||
return user
|
||||
|
||||
client = TestClient(_app(setup))
|
||||
response = client.get("/me", headers={"Authorization": f"Bearer {VALID_TOKEN}"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"user": "alice"}
|
||||
|
||||
@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
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extract_empty_token(self):
|
||||
from starlette.requests import Request
|
||||
|
||||
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
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extract_token(self):
|
||||
from starlette.requests import Request
|
||||
|
||||
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"
|
||||
|
||||
|
||||
class TestSyncValidators:
|
||||
"""Sync (non-async) validators — covers the sync path in _call_validator."""
|
||||
|
||||
@@ -849,36 +755,6 @@ class TestSyncValidators:
|
||||
assert response.json() == {"user": "alice"}
|
||||
|
||||
|
||||
class TestOpenIDAuthEdgeCases:
|
||||
DISCOVERY_URL = "https://accounts.example.com/.well-known/openid-configuration"
|
||||
|
||||
def test_non_bearer_authorization_returns_401(self):
|
||||
"""Authorization header present but not Bearer scheme."""
|
||||
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||
|
||||
def setup(app: FastAPI):
|
||||
@app.get("/me")
|
||||
async def me(user=Security(oidc)):
|
||||
return user
|
||||
|
||||
client = TestClient(_app(setup))
|
||||
response = client.get("/me", headers={"Authorization": "Basic dXNlcjpwYXNz"})
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_empty_bearer_token_returns_401(self):
|
||||
"""Authorization: Bearer with no token after the scheme prefix."""
|
||||
oidc = OpenIDAuth(self.DISCOVERY_URL, simple_validator)
|
||||
|
||||
def setup(app: FastAPI):
|
||||
@app.get("/me")
|
||||
async def me(user=Security(oidc)):
|
||||
return user
|
||||
|
||||
client = TestClient(_app(setup))
|
||||
response = client.get("/me", headers={"Authorization": "Bearer "})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCookieAuthSigned:
|
||||
"""CookieAuth with HMAC-SHA256 signed cookies (secret_key path)."""
|
||||
|
||||
@@ -1029,14 +905,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."""
|
||||
@@ -1098,3 +970,214 @@ class TestAuthSource:
|
||||
response = client.get("/me", headers={"X-Token": "s3cr3t"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"token": "s3cr3t"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_async_client_mock(get_return=None, post_return=None):
|
||||
"""Return a patched httpx.AsyncClient context-manager mock."""
|
||||
mock_client = AsyncMock()
|
||||
if get_return is not None:
|
||||
mock_client.get.return_value = get_return
|
||||
if post_return is not None:
|
||||
mock_client.post.return_value = post_return
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
return cm, mock_client
|
||||
|
||||
|
||||
class TestEncodeDecodeOAuthState:
|
||||
def test_encode_returns_base64url_string(self):
|
||||
result = encode_oauth_state("https://example.com/dashboard")
|
||||
assert isinstance(result, str)
|
||||
assert "+" not in result
|
||||
assert "/" not in result
|
||||
|
||||
def test_round_trip(self):
|
||||
url = "https://example.com/after-login?next=/home"
|
||||
assert decode_oauth_state(encode_oauth_state(url), fallback="/") == url
|
||||
|
||||
def test_decode_none_returns_fallback(self):
|
||||
assert decode_oauth_state(None, fallback="/home") == "/home"
|
||||
|
||||
def test_decode_null_string_returns_fallback(self):
|
||||
assert decode_oauth_state("null", fallback="/home") == "/home"
|
||||
|
||||
def test_decode_invalid_base64_returns_fallback(self):
|
||||
assert decode_oauth_state("!!!notbase64!!!", fallback="/home") == "/home"
|
||||
|
||||
def test_decode_handles_missing_padding(self):
|
||||
url = "https://example.com/x"
|
||||
encoded = encode_oauth_state(url).rstrip("=")
|
||||
assert decode_oauth_state(encoded, fallback="/") == url
|
||||
|
||||
|
||||
class TestBuildAuthorizationRedirect:
|
||||
def test_returns_redirect_response(self):
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
response = build_authorization_redirect(
|
||||
"https://auth.example.com/authorize",
|
||||
client_id="my-client",
|
||||
scopes="openid email",
|
||||
redirect_uri="https://app.example.com/callback",
|
||||
destination="https://app.example.com/dashboard",
|
||||
)
|
||||
assert isinstance(response, RedirectResponse)
|
||||
|
||||
def test_redirect_location_contains_all_params(self):
|
||||
response = build_authorization_redirect(
|
||||
"https://auth.example.com/authorize",
|
||||
client_id="my-client",
|
||||
scopes="openid email",
|
||||
redirect_uri="https://app.example.com/callback",
|
||||
destination="https://app.example.com/dashboard",
|
||||
)
|
||||
location = response.headers["location"]
|
||||
parsed = urlparse(location)
|
||||
assert (
|
||||
parsed.scheme + "://" + parsed.netloc + parsed.path
|
||||
== "https://auth.example.com/authorize"
|
||||
)
|
||||
params = parse_qs(parsed.query)
|
||||
assert params["client_id"] == ["my-client"]
|
||||
assert params["response_type"] == ["code"]
|
||||
assert params["scope"] == ["openid email"]
|
||||
assert params["redirect_uri"] == ["https://app.example.com/callback"]
|
||||
assert (
|
||||
decode_oauth_state(params["state"][0], fallback="")
|
||||
== "https://app.example.com/dashboard"
|
||||
)
|
||||
|
||||
|
||||
class TestResolveProviderUrls:
|
||||
def _discovery(self, *, userinfo=True):
|
||||
doc = {
|
||||
"authorization_endpoint": "https://auth.example.com/authorize",
|
||||
"token_endpoint": "https://auth.example.com/token",
|
||||
}
|
||||
if userinfo:
|
||||
doc["userinfo_endpoint"] = "https://auth.example.com/userinfo"
|
||||
return doc
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_returns_all_endpoints(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = self._discovery()
|
||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||
|
||||
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
||||
with patch("httpx.AsyncClient", return_value=cm):
|
||||
auth_url, token_url, userinfo_url = await resolve_provider_urls(
|
||||
"https://auth.example.com/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
assert auth_url == "https://auth.example.com/authorize"
|
||||
assert token_url == "https://auth.example.com/token"
|
||||
assert userinfo_url == "https://auth.example.com/userinfo"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_userinfo_url_none_when_absent(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = self._discovery(userinfo=False)
|
||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||
|
||||
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
||||
with patch("httpx.AsyncClient", return_value=cm):
|
||||
_, _, userinfo_url = await resolve_provider_urls(
|
||||
"https://auth.example.com/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
assert userinfo_url is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_caches_discovery_document(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = self._discovery()
|
||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||
|
||||
url = "https://auth.example.com/.well-known/openid-configuration"
|
||||
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
||||
with patch("httpx.AsyncClient", return_value=cm):
|
||||
await resolve_provider_urls(url)
|
||||
await resolve_provider_urls(url)
|
||||
|
||||
assert mock_client.get.call_count == 1
|
||||
|
||||
|
||||
class TestFetchUserinfo:
|
||||
@pytest.mark.anyio
|
||||
async def test_returns_userinfo_payload(self):
|
||||
token_resp = MagicMock()
|
||||
token_resp.raise_for_status = MagicMock()
|
||||
token_resp.json.return_value = {"access_token": "tok123"}
|
||||
|
||||
userinfo_resp = MagicMock()
|
||||
userinfo_resp.raise_for_status = MagicMock()
|
||||
userinfo_resp.json.return_value = {
|
||||
"sub": "user-1",
|
||||
"email": "alice@example.com",
|
||||
}
|
||||
|
||||
cm, mock_client = _make_async_client_mock(
|
||||
post_return=token_resp, get_return=userinfo_resp
|
||||
)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=cm):
|
||||
result = await fetch_userinfo(
|
||||
token_url="https://auth.example.com/token",
|
||||
userinfo_url="https://auth.example.com/userinfo",
|
||||
code="authcode123",
|
||||
client_id="client-id",
|
||||
client_secret="client-secret",
|
||||
redirect_uri="https://app.example.com/callback",
|
||||
)
|
||||
|
||||
assert result == {"sub": "user-1", "email": "alice@example.com"}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_posts_correct_token_request_and_uses_bearer(self):
|
||||
token_resp = MagicMock()
|
||||
token_resp.raise_for_status = MagicMock()
|
||||
token_resp.json.return_value = {"access_token": "tok123"}
|
||||
|
||||
userinfo_resp = MagicMock()
|
||||
userinfo_resp.raise_for_status = MagicMock()
|
||||
userinfo_resp.json.return_value = {}
|
||||
|
||||
cm, mock_client = _make_async_client_mock(
|
||||
post_return=token_resp, get_return=userinfo_resp
|
||||
)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=cm):
|
||||
await fetch_userinfo(
|
||||
token_url="https://auth.example.com/token",
|
||||
userinfo_url="https://auth.example.com/userinfo",
|
||||
code="authcode123",
|
||||
client_id="my-client",
|
||||
client_secret="my-secret",
|
||||
redirect_uri="https://app.example.com/callback",
|
||||
)
|
||||
|
||||
mock_client.post.assert_called_once_with(
|
||||
"https://auth.example.com/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": "authcode123",
|
||||
"client_id": "my-client",
|
||||
"client_secret": "my-secret",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
mock_client.get.assert_called_once_with(
|
||||
"https://auth.example.com/userinfo",
|
||||
headers={"Authorization": "Bearer tok123"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user