Pico-Client-Auth
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
Why pico-client-auth?
| 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 |
Core Features
- 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 control@requires_scope("treasury:*")for agent-identity authorization (dual-token, see below)SecurityContextaccessible from controllers, services, and any code within a request- JWKS fetch with TTL cache and automatic key rotation
- Optional
jtirevocation denylist with bounded propagation (opt-in) - 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)
Installation
pip install pico-client-auth
# With post-quantum (ML-DSA) support
pip install pico-client-auth[pqc]
Quick Example
# application.yaml
auth_client:
issuer: https://auth.example.com
audience: my-api
from 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 protected
Quick Example (without pico-boot)
from 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)
SecurityContext
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 InsufficientPermissionsError
Agent Identity & Scopes (v0.4.2+)
A request can carry two tokens. Authorization: Bearer <service-token> proves which service is calling ( SecurityContext). X-Agent-Authorization: Bearer <agent-token> proves which LLM agent is acting, on behalf of which user, with which scopes ( AgentContext). Both are validated through the same TokenValidator/JWKS.
Gate an endpoint on agent scopes with @requires_scope — scope matching is a :-segmented glob, so treasury:* matches treasury:write:budget:opex:
from pico_client_auth import requires_scope, AgentContext
@app.get("/treasury/payments")
@requires_role("treasury-service") # service identity (Authorization)
@requires_scope("treasury:write:*") # agent identity (X-Agent-Authorization)
async def make_payment():
agent = AgentContext.get() # AgentClaims | None
return {"agent": agent.sub, "user": agent.user_id, "scopes": agent.scopes}
- An endpoint with
@requires_scopereturns 401 if the agent header is missing and 403 if its scopes don't satisfy the requirement. - Endpoints without
@requires_scopeignore the agent header (it's optional;AgentContextis still populated if a valid one is present).
Revocation denylist (opt-in)
Set revocation_endpoint to have the validator reject tokens whose jti is on the issuer's denylist. The cache is polled every revocation_ttl_seconds (default 15s — the worst-case window between an operator revoking and validators rejecting). Empty endpoint (default) = signature-only validation. JWKS rotation remains the instant-kill path.
If a poll fails, tokens carrying a jti are rejected: a denylist that cannot be reached means revocation status cannot be confirmed. Set revocation_fail_open: true to accept them instead, trading prompt revocation for availability.
Token Validation Hardening (v0.6.0+)
Three rules the validator enforces unconditionally, independent of configuration:
expis mandatory. A token without an expiry is rejected rather than treated as non-expiring.nbfis enforced when present.- Symmetric
HS*algorithms are rejected, even if listed inaccepted_algorithms. This path verifies with public keys, so anHS256token implies an RS/HS confusion attack — signing with the public key as the HMAC secret. - JWKS and revocation endpoints must be
https. Plainhttpis permitted only forlocalhost,127.0.0.1and::1, so development still works.
Custom Role Resolver
Override 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", [])
Configuration
| 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 |
auth_client.revocation_endpoint |
"" |
jti denylist URL to poll. Empty = revocation disabled |
auth_client.revocation_ttl_seconds |
15 |
Poll interval / worst-case revokereject window |
auth_client.revocation_bearer |
"" |
Optional bearer token for the revocation endpoint |
auth_client.revocation_fail_open |
false |
Accept tokens when the denylist fetch fails instead of rejecting them |
Testing
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.
Post-Quantum (ML-DSA) Support
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-65
ML-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.
How It Works
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 PyJWT, 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
Built for AI-assisted development
pico-client-auth is part of an ecosystem designed for humans and coding agents building software together. Every package ships AGENTS.md working conventions, an llms.txt machine-readable docs index and documented behaviour pinned by regression tests; pico-testing gives agents a verification loop for their own changes, and releases are gated by the whole ecosystem booting together against real infrastructure. The full story: Built for AI-assisted development.
Install the agent skills for Claude Code or OpenAI Codex:
curl -sL https://raw.githubusercontent.com/dperezcabrera/pico-skills/main/install.sh | bash
The pico-conventions skill teaches the assistant this module's API surface and invariants; /add-component and /add-tests scaffold components and tests that use it.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pico_client_auth-0.6.0.tar.gz.
File metadata
- Download URL: pico_client_auth-0.6.0.tar.gz
- Upload date:
- Size: 92.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fe846df4462484ede42105e10670061c18f3e20761edafd31135fdda53400e15
|
|
| MD5 |
55a904020c310eb58118790187ce5e26
|
|
| BLAKE2b-256 |
ce177b5e835a2237f1f5cf5cfc3f7bb546b89cb4502d6be66d3bca304f434376
|
Provenance
The following attestation bundles were made for pico_client_auth-0.6.0.tar.gz:
Publisher:
publish-to-pypi.yml on dperezcabrera/pico-client-auth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pico_client_auth-0.6.0.tar.gz -
Subject digest:
fe846df4462484ede42105e10670061c18f3e20761edafd31135fdda53400e15 - Sigstore transparency entry: 2334096065
- Sigstore integration time:
-
Permalink:
dperezcabrera/pico-client-auth@6d67321d587386a216989e8d4b735713ac9ed9a6 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/dperezcabrera
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-to-pypi.yml@6d67321d587386a216989e8d4b735713ac9ed9a6 -
Trigger Event:
release
-
Statement type:
File details
Details for the file pico_client_auth-0.6.0-py3-none-any.whl.
File metadata
- Download URL: pico_client_auth-0.6.0-py3-none-any.whl
- Upload date:
- Size: 27.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
51e81cf1099e387c43eedb04874e665f92a04250e0b4d662614ff5e54741b372
|
|
| MD5 |
33ef672254b5b8f17eab4df6d7e14f56
|
|
| BLAKE2b-256 |
1d3d68d137577f048e556e143cbe05b7c4e9509a2fe0c18713a966caad022fa9
|
Provenance
The following attestation bundles were made for pico_client_auth-0.6.0-py3-none-any.whl:
Publisher:
publish-to-pypi.yml on dperezcabrera/pico-client-auth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pico_client_auth-0.6.0-py3-none-any.whl -
Subject digest:
51e81cf1099e387c43eedb04874e665f92a04250e0b4d662614ff5e54741b372 - Sigstore transparency entry: 2334096071
- Sigstore integration time:
-
Permalink:
dperezcabrera/pico-client-auth@6d67321d587386a216989e8d4b735713ac9ed9a6 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/dperezcabrera
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-to-pypi.yml@6d67321d587386a216989e8d4b735713ac9ed9a6 -
Trigger Event:
release
-
Statement type: