Skip to content

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