How to Test Authenticated Endpoints¶
This guide covers testing pico-client-auth protected endpoints using mock tokens, JWKS fixtures, and role overrides.
Basic Setup¶
A typical test creates an RSA keypair, signs test tokens, and mocks the JWKS client:
# conftest.py
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from jose import jwt
from datetime import datetime, timedelta, UTC
import base64
@pytest.fixture(scope="session")
def rsa_keypair():
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return private_key, private_key.public_key()
@pytest.fixture(scope="session")
def rsa_private_pem(rsa_keypair):
private_key, _ = rsa_keypair
return private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
@pytest.fixture(scope="session")
def jwk_dict(rsa_keypair):
_, public_key = rsa_keypair
numbers = public_key.public_numbers()
n_bytes = (numbers.n.bit_length() + 7) // 8
def b64url(value, length):
return base64.urlsafe_b64encode(
value.to_bytes(length, "big")
).rstrip(b"=").decode()
return {
"kty": "RSA", "kid": "test-key-1", "use": "sig", "alg": "RS256",
"n": b64url(numbers.n, n_bytes), "e": b64url(numbers.e, 3),
}
@pytest.fixture(scope="session")
def make_token(rsa_private_pem):
def _make(sub="user-1", role="admin", **overrides):
now = datetime.now(UTC)
payload = {
"sub": sub, "email": f"{sub}@test.com", "role": role,
"org_id": "org-1", "jti": "tok-1",
"iss": "https://auth.example.com", "aud": "my-api",
"iat": int(now.timestamp()),
"exp": int((now + timedelta(hours=1)).timestamp()),
**overrides,
}
return jwt.encode(payload, rsa_private_pem.decode(),
algorithm="RS256", headers={"kid": "test-key-1"})
return _make
Testing with a FastAPI Test App¶
Build a minimal app with the auth middleware for integration tests:
import pytest
from unittest.mock import AsyncMock
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from pico_client_auth.config import AuthClientSettings
from pico_client_auth.configurer import AuthFastapiConfigurer
from pico_client_auth.decorators import allow_anonymous, requires_role
from pico_client_auth.jwks_client import JWKSClient
from pico_client_auth.role_resolver import DefaultRoleResolver
from pico_client_auth.security_context import SecurityContext
from pico_client_auth.token_validator import TokenValidator
@pytest.fixture
def app(jwk_dict):
settings = AuthClientSettings(
enabled=True,
issuer="https://auth.example.com",
audience="my-api",
jwks_ttl_seconds=300,
jwks_endpoint="https://auth.example.com/jwks",
)
mock_jwks = AsyncMock(spec=JWKSClient)
mock_jwks.get_key = AsyncMock(return_value=jwk_dict)
validator = TokenValidator(settings=settings, jwks_client=mock_jwks)
resolver = DefaultRoleResolver()
configurer = AuthFastapiConfigurer(
settings=settings, token_validator=validator, role_resolver=resolver,
)
app = FastAPI()
configurer.configure_app(app)
@app.get("/protected")
async def protected():
claims = SecurityContext.require()
return {"sub": claims.sub}
@app.get("/public")
@allow_anonymous
async def public():
return {"ok": True}
@app.get("/admin")
@requires_role("admin")
async def admin():
return {"admin": True}
return app
@pytest.fixture
async def client(app):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
yield c
Test Cases¶
401 Without Token¶
@pytest.mark.asyncio
async def test_no_token_returns_401(client):
resp = await client.get("/protected")
assert resp.status_code == 401
200 With Valid Token¶
@pytest.mark.asyncio
async def test_valid_token(client, make_token):
token = make_token(sub="alice")
resp = await client.get("/protected",
headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json()["sub"] == "alice"
@allow_anonymous Works Without Token¶
@pytest.mark.asyncio
async def test_anonymous_endpoint(client):
resp = await client.get("/public")
assert resp.status_code == 200
@requires_role Returns 403¶
@pytest.mark.asyncio
async def test_missing_role_returns_403(client, make_token):
token = make_token(role="viewer")
resp = await client.get("/admin",
headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 403
@requires_role Passes With Correct Role¶
@pytest.mark.asyncio
async def test_correct_role(client, make_token):
token = make_token(role="admin")
resp = await client.get("/admin",
headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
Testing with Container Overrides¶
If using pico-boot, you can override the RoleResolver in tests:
from pico_boot import init
from pico_client_auth import RoleResolver
class FixedRoleResolver:
async def resolve(self, claims, raw_claims):
return ["admin", "editor"] # Always return these roles
container = init(
modules=["myapp"],
overrides={RoleResolver: FixedRoleResolver()},
config=config,
)
Testing SecurityContext Directly¶
Unit test SecurityContext without HTTP:
from pico_client_auth import SecurityContext, TokenClaims
from pico_client_auth.errors import MissingTokenError
def test_require_raises_when_empty():
SecurityContext.clear()
with pytest.raises(MissingTokenError):
SecurityContext.require()
def test_set_and_get():
claims = TokenClaims(sub="u1", email="a@b.com", role="admin",
org_id="o1", jti="j1")
SecurityContext.set(claims, ["admin"])
assert SecurityContext.require().sub == "u1"
assert SecurityContext.has_role("admin")
SecurityContext.clear()
Testing Post-Quantum (ML-DSA) Tokens¶
PQC test fixtures are available in conftest.py and skip automatically when liboqs is not installed:
import pytest
def test_mldsa65_token(mldsa65_keypair, mldsa65_jwk_dict, make_pqc_token):
oqs = pytest.importorskip("oqs")
public_key, secret_key = mldsa65_keypair
token = make_pqc_token(secret_key, algorithm="ML-DSA-65", kid="pqc-key-65")
# Use with TokenValidator
mock_jwks = AsyncMock(spec=JWKSClient)
mock_jwks.get_key = AsyncMock(return_value=mldsa65_jwk_dict)
settings = AuthClientSettings(
enabled=True,
issuer="https://auth.example.com",
audience="my-api",
jwks_endpoint="https://auth.example.com/jwks",
accepted_algorithms=("RS256", "ML-DSA-65"),
)
validator = TokenValidator(settings=settings, jwks_client=mock_jwks)
claims, raw = await validator.validate(token)
assert claims.sub == "user-123"
Available PQC fixtures:
| Fixture | Description |
|---|---|
mldsa65_keypair | (public_key, secret_key) for ML-DSA-65 |
mldsa87_keypair | (public_key, secret_key) for ML-DSA-87 |
mldsa65_jwk_dict | AKP JWK dict for the ML-DSA-65 key |
mldsa87_jwk_dict | AKP JWK dict for the ML-DSA-87 key |
make_pqc_token | Factory: make_pqc_token(secret_key, algorithm="ML-DSA-65", ...) |
Run PQC tests in Docker: make pqc-test
Common Testing Pitfalls¶
| Problem | Cause | Fix |
|---|---|---|
| 401 on all requests | Mock JWKS not returning the right key | Ensure mock_jwks.get_key returns the JWK matching your token's kid |
| SecurityContext leaks between tests | Missing clear() | Add SecurityContext.clear() in a fixture with autouse=True |
| Expired token errors | Test token uses real time | Ensure exp is set far in the future |
| Wrong issuer/audience | Settings don't match make_token | Align issuer/audience in both |
| PQC tests not running | liboqs not installed | Use make pqc-test or tox -e pqc-py312 with liboqs |