Compare commits

...
2 Commits
Author SHA1 Message Date
d3vyce 6fa135ef2a feat(security): add oauth helpers 2026-03-07 10:29:20 -05:00
d3vyce aa9a419e01 wip4 2026-03-07 06:33:58 -05:00
8 changed files with 568 additions and 399 deletions
+14 -3
View File
@@ -1,13 +1,24 @@
"""Authentication helpers for FastAPI using Security().""" """Authentication helpers for FastAPI using Security()."""
from .abc import AuthSource 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__ = [ __all__ = [
"APIKeyHeaderAuth",
"AuthSource", "AuthSource",
"BearerTokenAuth", "BearerTokenAuth",
"CookieAuth", "CookieAuth",
"MultiAuth", "MultiAuth",
"OAuth2Auth", "build_authorization_redirect",
"OpenIDAuth", "decode_oauth_state",
"fetch_userinfo",
"encode_oauth_state",
"resolve_provider_urls",
] ]
+138
View File
@@ -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.""" """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 .multi import MultiAuth 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},
)
+24 -1
View File
@@ -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,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
View File
@@ -1,17 +1,24 @@
"""Tests for fastapi_toolsets.security.""" """Tests for fastapi_toolsets.security."""
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlparse
import pytest import pytest
from fastapi import FastAPI, Security from fastapi import FastAPI, Security
from fastapi.testclient import TestClient 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, build_authorization_redirect,
OpenIDAuth, decode_oauth_state,
encode_oauth_state,
fetch_userinfo,
resolve_provider_urls,
) )
@@ -316,73 +323,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 +409,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):
@@ -626,26 +687,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,141 +712,6 @@ class TestRequire:
assert response.json() == {"user": "alice", "role": "admin"} 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: class TestSyncValidators:
"""Sync (non-async) validators — covers the sync path in _call_validator.""" """Sync (non-async) validators — covers the sync path in _call_validator."""
@@ -849,36 +755,6 @@ class TestSyncValidators:
assert response.json() == {"user": "alice"} 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: class TestCookieAuthSigned:
"""CookieAuth with HMAC-SHA256 signed cookies (secret_key path).""" """CookieAuth with HMAC-SHA256 signed cookies (secret_key path)."""
@@ -1029,14 +905,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."""
@@ -1098,3 +970,214 @@ class TestAuthSource:
response = client.get("/me", headers={"X-Token": "s3cr3t"}) response = client.get("/me", headers={"X-Token": "s3cr3t"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"token": "s3cr3t"} 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"},
)