Pico-Client-Auth provides JWT authentication for pico-fastapi applications. It integrates with the pico-ioc container to deliver automatic Bearer token validation, a request-scoped SecurityContext, role-based access control, and JWKS key rotation support.
Requires Python 3.11+ Built on pico-fastapi + pico-ioc Fully async-compatible Real JWKS-based token validation Auth by default with opt-out via
@allow_anonymous
| Concern | DIY Middleware | pico-client-auth |
|---|---|---|
| Token validation | Implement yourself | Built-in with JWKS |
| Key rotation | Manual handling | Automatic on unknown kid |
| Security context | request.state ad-hoc |
Typed SecurityContext with ContextVar |
| Role checking | Scattered if/else | @requires_role decorator |
| Configuration | Hardcoded | @configured from YAML/env |
| Testing | Build your own fixtures | RSA keypair + make_token pattern |
- Auth by default on all routes
@allow_anonymousto opt out specific endpoints@requires_role("admin")for declarative role-based authorization@requires_group("team-id")for group-based access controlSecurityContextaccessible from controllers, services, and any code within a request- JWKS fetch with TTL cache and automatic key rotation
- Extensible
RoleResolverprotocol - Fail-fast startup if issuer/audience are missing
- Auto-discovered via
pico_boot.modulesentry point - Post-quantum ready: ML-DSA-65 / ML-DSA-87 signature verification (optional
pqcextra)
pip install pico-client-auth
# With post-quantum (ML-DSA) support
pip install pico-client-auth[pqc]# application.yaml
auth_client:
issuer: https://auth.example.com
audience: my-apifrom pico_fastapi import controller, get
from pico_client_auth import SecurityContext, allow_anonymous, requires_role, requires_group
@controller(prefix="/api")
class ApiController:
@get("/me")
async def get_me(self):
claims = SecurityContext.require()
return {"sub": claims.sub, "email": claims.email}
@get("/health")
@allow_anonymous
async def health(self):
return {"status": "ok"}
@get("/admin")
@requires_role("admin")
async def admin_panel(self):
return {"admin": True}from pico_boot import init
from pico_ioc import configuration, YamlTreeSource
from fastapi import FastAPI
config = configuration(YamlTreeSource("application.yaml"))
container = init(modules=["controllers"], config=config)
app = container.get(FastAPI)
# pico-client-auth is auto-discovered — all routes are now protectedfrom pico_ioc import init, configuration, YamlTreeSource
from fastapi import FastAPI
config = configuration(YamlTreeSource("application.yaml"))
container = init(
modules=[
"controllers",
"pico_fastapi",
"pico_client_auth", # Required without pico-boot
],
config=config,
)
app = container.get(FastAPI)Access authenticated user information from anywhere within a request:
from pico_client_auth import SecurityContext
# In controller, service, or repository
claims = SecurityContext.require() # TokenClaims (raises if not auth'd)
claims = SecurityContext.get() # TokenClaims | None
roles = SecurityContext.get_roles() # list[str]
SecurityContext.has_role("admin") # bool
SecurityContext.require_role("admin") # raises InsufficientPermissionsError
groups = SecurityContext.get_groups() # tuple[str, ...]
SecurityContext.has_group("team-id") # bool
SecurityContext.require_group("team") # raises InsufficientPermissionsErrorOverride how roles are extracted from tokens:
from pico_ioc import component
from pico_client_auth import RoleResolver, TokenClaims
@component
class MyRoleResolver:
async def resolve(self, claims: TokenClaims, raw_claims: dict) -> list[str]:
return raw_claims.get("roles", [])| Key | Default | Description |
|---|---|---|
auth_client.enabled |
true |
Enable/disable auth middleware |
auth_client.issuer |
"" |
Expected JWT issuer (iss claim) |
auth_client.audience |
"" |
Expected JWT audience (aud claim) |
auth_client.jwks_ttl_seconds |
300 |
JWKS cache TTL in seconds |
auth_client.jwks_endpoint |
"" |
JWKS URL (default: {issuer}/api/v1/auth/jwks) |
auth_client.accepted_algorithms |
["RS256"] |
List of accepted JWT signing algorithms |
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_authenticated_flow():
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()For full e2e testing with mock JWKS and signed tokens, see the Testing Guide.
pico-client-auth supports ML-DSA-65 (NIST Level 3) and ML-DSA-87 (NIST Level 5) post-quantum signature verification via the optional pqc extra.
auth_client:
issuer: https://auth.example.com
audience: my-api
accepted_algorithms:
- RS256
- ML-DSA-65ML-DSA tokens use the draft-ietf-cose-dilithium JOSE standard:
- kty:
"AKP"(Algorithm Key Pair) - alg:
"ML-DSA-65"or"ML-DSA-87" - pub: base64url-encoded raw public key
Requires liboqs-python (installed automatically with pip install pico-client-auth[pqc]). When liboqs is not installed, ML-DSA tokens are rejected with AuthConfigurationError.
AuthFastapiConfigurer(priority=10) registers as an inner middleware- Every request: extract Bearer token → validate JWT via JWKS → resolve roles → populate SecurityContext
- Algorithm dispatch: RS256 tokens use python-jose, ML-DSA tokens use liboqs
@allow_anonymousendpoints skip validation entirely@requires_roleendpoints check resolved roles, return 403 if missing@requires_groupendpoints check group membership, return 403 if missing- SecurityContext is cleared in
finally— no leakage between requests
MIT