API Reference
Complete reference documentation for pico-client-auth's public API.
Module: pico_client_auth
Decorators
| Decorator | Description |
@allow_anonymous | Skip authentication for an endpoint |
@requires_role(*roles) | Require at least one of the specified roles |
Classes
| Class | Description |
SecurityContext | Static accessor for authenticated claims and roles |
TokenClaims | Frozen dataclass with JWT claim fields |
RoleResolver | Protocol for custom role extraction |
AuthClientSettings | Configuration dataclass for auth settings |
Exceptions
| Exception | Description |
AuthClientError | Base exception for all pico-client-auth errors |
MissingTokenError | No Bearer token found in request |
TokenExpiredError | JWT token has expired |
TokenInvalidError | JWT is malformed or has invalid signature |
InsufficientPermissionsError | User lacks required role(s) |
AuthConfigurationError | Auth configuration is missing or invalid |
Decorator Reference
@allow_anonymous
Marks an endpoint as publicly accessible without authentication.
@allow_anonymous
async def my_handler(): ...
Sets _pico_allow_anonymous = True on the function. The auth middleware checks this attribute before validating the token.
@requires_role
Requires the authenticated user to have at least one of the specified roles.
@requires_role(*roles: str)
Parameters:
| Parameter | Type | Description |
*roles | str | One or more role names (user must have at least one) |
Example:
@requires_role("admin")
async def admin_only(): ...
@requires_role("editor", "admin")
async def editor_or_admin(): ...
Sets _pico_required_roles = frozenset(roles) on the function. The middleware checks this after token validation.
Class Reference
SecurityContext
Static class for accessing authenticated user state within a request.
| Method | Returns | Raises | Description |
get() | TokenClaims \| None | -- | Current claims or None |
require() | TokenClaims | MissingTokenError | Current claims (must exist) |
get_roles() | list[str] | -- | Resolved roles (returns copy) |
has_role(role) | bool | -- | Check if user has role |
require_role(*roles) | None | InsufficientPermissionsError | Assert at least one role |
set(claims, roles) | None | -- | Internal: populate context |
clear() | None | -- | Internal: clear context |
TokenClaims
Immutable dataclass representing essential JWT claims.
@dataclass(frozen=True)
class TokenClaims:
sub: str # Subject (user ID)
email: str # User email
role: str # Primary role claim
org_id: str # Organisation ID
jti: str # JWT ID
RoleResolver (Protocol)
Protocol for custom role extraction from JWT claims.
@runtime_checkable
class RoleResolver(Protocol):
async def resolve(self, claims: TokenClaims, raw_claims: dict) -> list[str]: ...
The default implementation (DefaultRoleResolver) returns [claims.role] if non-empty, else [].
Override by registering a @component that satisfies this protocol.
AuthClientSettings
Configuration dataclass loaded from the auth_client config prefix.
@configured(target="self", prefix="auth_client", mapping="tree")
@dataclass
class AuthClientSettings:
enabled: bool = True
issuer: str = ""
audience: str = ""
jwks_ttl_seconds: int = 300
jwks_endpoint: str = ""
accepted_algorithms: tuple[str, ...] = ("RS256",)
| Field | Type | Default | Description |
enabled | bool | True | Enable/disable auth middleware |
issuer | str | "" | Expected JWT issuer |
audience | str | "" | Expected JWT audience |
jwks_ttl_seconds | int | 300 | JWKS cache TTL (seconds) |
jwks_endpoint | str | "" | JWKS URL (default: {issuer}/api/v1/auth/jwks) |
accepted_algorithms | tuple[str, ...] | ("RS256",) | Accepted JWT signing algorithms (e.g. RS256, ML-DSA-65, ML-DSA-87) |
Exception Reference
AuthClientError
Base exception for all pico-client-auth errors.
try:
...
# pico-client-auth operations
except AuthClientError as e:
...
# Handle any auth error
MissingTokenError
Raised when no Bearer token is found in the Authorization header, or when SecurityContext.require() is called without an authenticated context.
TokenExpiredError
Raised when the JWT exp claim indicates the token has expired.
TokenInvalidError
Raised when the JWT is malformed, has an invalid signature, wrong issuer, or wrong audience.
InsufficientPermissionsError
Raised when SecurityContext.require_role() is called and the user lacks all specified roles.
AuthConfigurationError
Raised at startup if auth_client.enabled is True but issuer or audience are empty.
Auto-generated API
pico_client_auth
pico-client-auth: JWT authentication client for pico-fastapi.
Provides automatic Bearer token validation, a request-scoped SecurityContext, role-based access control decorators, JWKS key rotation support, and agentic identity propagation via X-Agent-Authorization + scope-based authorization.
Public API
Models: TokenClaims, AgentClaims Contexts: SecurityContext, AgentContext Decorators: allow_anonymous, requires_role, requires_group, requires_scope Helpers: scope_matches, any_scope_matches Protocols: RoleResolver Replaceable components: JWKSClient Configuration: AuthClientSettings Errors: AuthClientError, MissingTokenError, TokenExpiredError, TokenInvalidError, InsufficientPermissionsError, AuthConfigurationError
AgentClaims dataclass
Immutable representation of the agentic claims carried in the X-Agent-Authorization JWT.
Required claims (the issuer must populate these): - sub: agent identifier (e.g. "purchaser-agent-instance-42") - task_type: free-form ("interactive_purchase", "scheduled_audit") - user_id: the human in whose name the agent acts - scopes: list of scope strings the agent is allowed to invoke (e.g. "treasury:write:budget:opex")
Optional but recommended
session_id, conversation_id: the conversational context task_id: per-task identifier (for spend-limit accounting) org_id: the customer's tenant role: an agent role (separate from the service role) spend_limit: decimal string — total budget for this task parent_chain: tuple of mcp-ids that delegated this token down (innermost first: ["io.acme.head-agent", "io.acme.purchaser"])
raw_claims keeps the original dict so the consumer can read custom fields without a round-trip through this dataclass.
Source code in src/pico_client_auth/agent_context.py
| @dataclass(frozen=True)
class AgentClaims:
"""Immutable representation of the agentic claims carried in the
`X-Agent-Authorization` JWT.
Required claims (the issuer must populate these):
- `sub`: agent identifier (e.g. "purchaser-agent-instance-42")
- `task_type`: free-form ("interactive_purchase", "scheduled_audit")
- `user_id`: the human in whose name the agent acts
- `scopes`: list of scope strings the agent is allowed to invoke
(e.g. "treasury:write:budget:opex")
Optional but recommended:
- `session_id`, `conversation_id`: the conversational context
- `task_id`: per-task identifier (for spend-limit accounting)
- `org_id`: the customer's tenant
- `role`: an agent role (separate from the service role)
- `spend_limit`: decimal string — total budget for this task
- `parent_chain`: tuple of mcp-ids that delegated this token down
(innermost first: ["io.acme.head-agent",
"io.acme.purchaser"])
`raw_claims` keeps the original dict so the consumer can read
custom fields without a round-trip through this dataclass."""
sub: str = ""
task_type: str = ""
user_id: str = ""
session_id: str = ""
conversation_id: str = ""
task_id: str = ""
org_id: str = ""
role: str = ""
spend_limit: str = ""
scopes: tuple[str, ...] = ()
parent_chain: tuple[str, ...] = ()
raw_claims: dict[str, Any] | None = None
@classmethod
def from_claims_dict(cls, raw: dict[str, Any]) -> "AgentClaims":
"""Build an `AgentClaims` from a JWT payload dict."""
return cls(
sub=str(raw.get("sub", "")),
task_type=str(raw.get("task_type", "")),
user_id=str(raw.get("user_id", "")),
session_id=str(raw.get("session_id", "")),
conversation_id=str(raw.get("conversation_id", "")),
task_id=str(raw.get("task_id", "")),
org_id=str(raw.get("org_id", "")),
role=str(raw.get("role", "")),
spend_limit=str(raw.get("spend_limit", "")),
scopes=tuple(raw.get("scopes") or []),
parent_chain=tuple(raw.get("parent_chain") or []),
raw_claims=dict(raw),
)
|
from_claims_dict(raw) classmethod
Build an AgentClaims from a JWT payload dict.
Source code in src/pico_client_auth/agent_context.py
| @classmethod
def from_claims_dict(cls, raw: dict[str, Any]) -> "AgentClaims":
"""Build an `AgentClaims` from a JWT payload dict."""
return cls(
sub=str(raw.get("sub", "")),
task_type=str(raw.get("task_type", "")),
user_id=str(raw.get("user_id", "")),
session_id=str(raw.get("session_id", "")),
conversation_id=str(raw.get("conversation_id", "")),
task_id=str(raw.get("task_id", "")),
org_id=str(raw.get("org_id", "")),
role=str(raw.get("role", "")),
spend_limit=str(raw.get("spend_limit", "")),
scopes=tuple(raw.get("scopes") or []),
parent_chain=tuple(raw.get("parent_chain") or []),
raw_claims=dict(raw),
)
|
AgentContext
Singleton-style accessor for the per-request agent identity.
Mirrors SecurityContext API. All methods are static; storage uses a ContextVar so each async task / thread has its own copy.
Source code in src/pico_client_auth/agent_context.py
| class AgentContext:
"""Singleton-style accessor for the per-request agent identity.
Mirrors `SecurityContext` API. All methods are static; storage uses
a `ContextVar` so each async task / thread has its own copy."""
@staticmethod
def get() -> Optional[AgentClaims]:
return _agent_var.get()
@staticmethod
def is_present() -> bool:
return _agent_var.get() is not None
@staticmethod
def get_scopes() -> tuple[str, ...]:
agent = _agent_var.get()
return agent.scopes if agent else ()
@staticmethod
def has_scope(scope: str) -> bool:
return scope in AgentContext.get_scopes()
@staticmethod
def set(agent: AgentClaims) -> None:
_agent_var.set(agent)
@staticmethod
def clear() -> None:
_agent_var.set(None)
|
AuthClientSettings dataclass
Type-safe settings for the auth client, loaded from configuration sources.
Populated automatically from configuration sources using the auth_client prefix via pico-ioc's @configured decorator.
Attributes:
| Name | Type | Description |
enabled | bool | Whether authentication middleware is active. |
issuer | str | Expected JWT issuer (iss claim). |
audience | str | Expected JWT audience (aud claim). |
jwks_ttl_seconds | int | How long to cache the JWKS key set (seconds). |
jwks_endpoint | str | URL to fetch JWKS from. Defaults to {issuer}/api/v1/auth/jwks. |
Source code in src/pico_client_auth/config.py
| @configured(target="self", prefix="auth_client", mapping="tree")
@dataclass
class AuthClientSettings:
"""Type-safe settings for the auth client, loaded from configuration sources.
Populated automatically from configuration sources using the ``auth_client``
prefix via pico-ioc's ``@configured`` decorator.
Attributes:
enabled: Whether authentication middleware is active.
issuer: Expected JWT issuer (``iss`` claim).
audience: Expected JWT audience (``aud`` claim).
jwks_ttl_seconds: How long to cache the JWKS key set (seconds).
jwks_endpoint: URL to fetch JWKS from. Defaults to ``{issuer}/api/v1/auth/jwks``.
"""
enabled: bool = True
issuer: str = ""
audience: str = ""
jwks_ttl_seconds: int = 300
jwks_endpoint: str = ""
accepted_algorithms: tuple[str, ...] = ("RS256",)
# ── Revocation denylist (jti) ────────────────────────────────
# Endpoint the validator polls to refresh its local cache of
# revoked JWT IDs. Empty disables the check entirely (back to
# signature-only validation — safe default for setups that
# haven't wired the issuer's revoke endpoint).
# ``revocation_ttl_seconds`` is the worst-case window between
# an operator clicking Revoke and validators actually
# rejecting the token. Lower = snappier, higher = fewer
# round-trips. JWKS rotation remains the instant-kill path.
revocation_endpoint: str = ""
revocation_ttl_seconds: int = 15
# SECURITY: on a revocation-fetch error, fail closed by default — treat the
# token as unable-to-confirm (revoked) rather than serving a stale denylist
# (fail-open). Set True to restore the old fail-open behaviour if availability
# matters more than promptly honouring revocations.
revocation_fail_open: bool = False
# If the revocation endpoint requires a Bearer token (the
# default — pico-server-auth gates it behind role=service),
# the auth-client uses this token for the poll. Empty falls
# back to anonymous (works for dev / unauthenticated setups).
revocation_bearer: str = ""
|
AuthClientError
Bases: Exception
Base error for all pico-client-auth exceptions.
Source code in src/pico_client_auth/errors.py
| class AuthClientError(Exception):
"""Base error for all pico-client-auth exceptions."""
|
AuthConfigurationError
Bases: AuthClientError
Authentication configuration is missing or invalid.
Source code in src/pico_client_auth/errors.py
| class AuthConfigurationError(AuthClientError):
"""Authentication configuration is missing or invalid."""
|
InsufficientPermissionsError
Bases: AuthClientError
The authenticated user lacks the required role(s).
Source code in src/pico_client_auth/errors.py
| class InsufficientPermissionsError(AuthClientError):
"""The authenticated user lacks the required role(s)."""
|
MissingTokenError
Bases: AuthClientError
No Bearer token found in the Authorization header.
Source code in src/pico_client_auth/errors.py
| class MissingTokenError(AuthClientError):
"""No Bearer token found in the Authorization header."""
|
TokenExpiredError
Bases: AuthClientError
The JWT token has expired.
Source code in src/pico_client_auth/errors.py
| class TokenExpiredError(AuthClientError):
"""The JWT token has expired."""
|
TokenInvalidError
Bases: AuthClientError
The JWT token is malformed or has an invalid signature.
Source code in src/pico_client_auth/errors.py
| class TokenInvalidError(AuthClientError):
"""The JWT token is malformed or has an invalid signature."""
|
JWKSClient
Fetches and caches the JSON Web Key Set from the auth server.
Supports automatic cache refresh when a key ID (kid) is not found (handles key rotation) and TTL-based expiration.
Source code in src/pico_client_auth/jwks_client.py
| @component
class JWKSClient:
"""Fetches and caches the JSON Web Key Set from the auth server.
Supports automatic cache refresh when a key ID (``kid``) is not found
(handles key rotation) and TTL-based expiration.
"""
def __init__(self, settings: AuthClientSettings):
self._settings = settings
self._keys: dict = {}
self._fetched_at: float = 0.0
self._endpoint = settings.jwks_endpoint or f"{settings.issuer.rstrip('/')}/api/v1/auth/jwks"
def _is_expired(self) -> bool:
return (time.monotonic() - self._fetched_at) >= self._settings.jwks_ttl_seconds
async def _fetch_keys(self) -> None:
logger.debug("Fetching JWKS from %s", self._endpoint)
# SECURITY: require TLS (https) for the JWKS endpoint and bound the
# request with a timeout so a hung server can't stall validation.
_require_https(self._endpoint)
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(self._endpoint)
response.raise_for_status()
data = response.json()
self._keys = {k["kid"]: k for k in data.get("keys", [])}
self._fetched_at = time.monotonic()
async def get_key(self, kid: str) -> dict:
"""Return the JWK for the given key ID, fetching/refreshing as needed.
Args:
kid: The ``kid`` header value from the JWT.
Returns:
The JWK dict matching the requested key ID.
Raises:
KeyError: If the key ID is not found even after a refresh.
"""
if self._is_expired() or not self._keys:
await self._fetch_keys()
if kid in self._keys:
return self._keys[kid]
# Key not found — might be key rotation, force refresh once
await self._fetch_keys()
if kid in self._keys:
return self._keys[kid]
raise KeyError(f"Key ID '{kid}' not found in JWKS")
|
get_key(kid) async
Return the JWK for the given key ID, fetching/refreshing as needed.
Parameters:
| Name | Type | Description | Default |
kid | str | The kid header value from the JWT. | required |
Returns:
| Type | Description |
dict | The JWK dict matching the requested key ID. |
Raises:
| Type | Description |
KeyError | If the key ID is not found even after a refresh. |
Source code in src/pico_client_auth/jwks_client.py
| async def get_key(self, kid: str) -> dict:
"""Return the JWK for the given key ID, fetching/refreshing as needed.
Args:
kid: The ``kid`` header value from the JWT.
Returns:
The JWK dict matching the requested key ID.
Raises:
KeyError: If the key ID is not found even after a refresh.
"""
if self._is_expired() or not self._keys:
await self._fetch_keys()
if kid in self._keys:
return self._keys[kid]
# Key not found — might be key rotation, force refresh once
await self._fetch_keys()
if kid in self._keys:
return self._keys[kid]
raise KeyError(f"Key ID '{kid}' not found in JWKS")
|
TokenClaims dataclass
Immutable representation of the essential JWT claims.
Attributes:
| Name | Type | Description |
sub | str | Subject identifier (user ID). |
email | str | |
role | str | Primary role claim from the token. |
org_id | str | |
jti | str | Unique token identifier (JWT ID). |
Source code in src/pico_client_auth/models.py
| @dataclass(frozen=True)
class TokenClaims:
"""Immutable representation of the essential JWT claims.
Attributes:
sub: Subject identifier (user ID).
email: User email address.
role: Primary role claim from the token.
org_id: Organisation identifier.
jti: Unique token identifier (JWT ID).
"""
sub: str
email: str
role: str
org_id: str
jti: str
groups: tuple[str, ...] = ()
|
RoleResolver
Bases: Protocol
Protocol for resolving user roles from JWT claims.
Implement this protocol to customise how roles are extracted (e.g. from a roles array claim, from an external service, etc.). Register your implementation as a @component and it will automatically replace the default resolver.
Source code in src/pico_client_auth/role_resolver.py
| @runtime_checkable
class RoleResolver(Protocol):
"""Protocol for resolving user roles from JWT claims.
Implement this protocol to customise how roles are extracted
(e.g. from a ``roles`` array claim, from an external service, etc.).
Register your implementation as a ``@component`` and it will
automatically replace the default resolver.
"""
async def resolve(self, claims: TokenClaims, raw_claims: dict) -> list[str]: ...
|
SecurityContext
Singleton-style accessor for the current request's authentication state.
All methods are static; the underlying storage uses ContextVar so each async task / thread has its own isolated copy.
Source code in src/pico_client_auth/security_context.py
| class SecurityContext:
"""Singleton-style accessor for the current request's authentication state.
All methods are static; the underlying storage uses ``ContextVar`` so
each async task / thread has its own isolated copy.
"""
@staticmethod
def get() -> Optional[TokenClaims]:
"""Return the current claims, or ``None`` if not authenticated."""
return _claims_var.get()
@staticmethod
def require() -> TokenClaims:
"""Return the current claims, raising if not authenticated.
Raises:
MissingTokenError: If no authentication context is set.
"""
claims = _claims_var.get()
if claims is None:
raise MissingTokenError("No authenticated user in SecurityContext")
return claims
@staticmethod
def get_roles() -> list[str]:
"""Return the resolved roles for the current request."""
return list(_roles_var.get())
@staticmethod
def has_role(role: str) -> bool:
"""Check whether the current user has the given role."""
return role in _roles_var.get()
@staticmethod
def require_role(*roles: str) -> None:
"""Assert that the current user has at least one of the given roles.
Raises:
InsufficientPermissionsError: If none of the roles match.
"""
current = set(_roles_var.get())
if not current.intersection(roles):
raise InsufficientPermissionsError(f"Required one of {roles}, but user has {sorted(current)}")
@staticmethod
def get_groups() -> tuple[str, ...]:
"""Return the group IDs for the current request."""
return _groups_var.get()
@staticmethod
def has_group(group_id: str) -> bool:
"""Check whether the current user belongs to the given group."""
return group_id in _groups_var.get()
@staticmethod
def require_group(*group_ids: str) -> None:
"""Assert that the current user belongs to at least one of the given groups.
Raises:
InsufficientPermissionsError: If none of the groups match.
"""
current = set(_groups_var.get())
if not current.intersection(group_ids):
raise InsufficientPermissionsError(f"Required one of groups {group_ids}, but user has {sorted(current)}")
@staticmethod
def set(claims: TokenClaims, roles: list[str]) -> None:
"""Populate the security context (called by the middleware)."""
_claims_var.set(claims)
_roles_var.set(roles)
_groups_var.set(claims.groups)
@staticmethod
def clear() -> None:
"""Clear the security context (called by the middleware in ``finally``)."""
_claims_var.set(None)
_roles_var.set([])
_groups_var.set(())
|
get() staticmethod
Return the current claims, or None if not authenticated.
Source code in src/pico_client_auth/security_context.py
| @staticmethod
def get() -> Optional[TokenClaims]:
"""Return the current claims, or ``None`` if not authenticated."""
return _claims_var.get()
|
require() staticmethod
Return the current claims, raising if not authenticated.
Raises:
Source code in src/pico_client_auth/security_context.py
| @staticmethod
def require() -> TokenClaims:
"""Return the current claims, raising if not authenticated.
Raises:
MissingTokenError: If no authentication context is set.
"""
claims = _claims_var.get()
if claims is None:
raise MissingTokenError("No authenticated user in SecurityContext")
return claims
|
get_roles() staticmethod
Return the resolved roles for the current request.
Source code in src/pico_client_auth/security_context.py
| @staticmethod
def get_roles() -> list[str]:
"""Return the resolved roles for the current request."""
return list(_roles_var.get())
|
has_role(role) staticmethod
Check whether the current user has the given role.
Source code in src/pico_client_auth/security_context.py
| @staticmethod
def has_role(role: str) -> bool:
"""Check whether the current user has the given role."""
return role in _roles_var.get()
|
require_role(*roles) staticmethod
Assert that the current user has at least one of the given roles.
Raises:
Source code in src/pico_client_auth/security_context.py
| @staticmethod
def require_role(*roles: str) -> None:
"""Assert that the current user has at least one of the given roles.
Raises:
InsufficientPermissionsError: If none of the roles match.
"""
current = set(_roles_var.get())
if not current.intersection(roles):
raise InsufficientPermissionsError(f"Required one of {roles}, but user has {sorted(current)}")
|
get_groups() staticmethod
Return the group IDs for the current request.
Source code in src/pico_client_auth/security_context.py
| @staticmethod
def get_groups() -> tuple[str, ...]:
"""Return the group IDs for the current request."""
return _groups_var.get()
|
has_group(group_id) staticmethod
Check whether the current user belongs to the given group.
Source code in src/pico_client_auth/security_context.py
| @staticmethod
def has_group(group_id: str) -> bool:
"""Check whether the current user belongs to the given group."""
return group_id in _groups_var.get()
|
require_group(*group_ids) staticmethod
Assert that the current user belongs to at least one of the given groups.
Raises:
Source code in src/pico_client_auth/security_context.py
| @staticmethod
def require_group(*group_ids: str) -> None:
"""Assert that the current user belongs to at least one of the given groups.
Raises:
InsufficientPermissionsError: If none of the groups match.
"""
current = set(_groups_var.get())
if not current.intersection(group_ids):
raise InsufficientPermissionsError(f"Required one of groups {group_ids}, but user has {sorted(current)}")
|
set(claims, roles) staticmethod
Populate the security context (called by the middleware).
Source code in src/pico_client_auth/security_context.py
| @staticmethod
def set(claims: TokenClaims, roles: list[str]) -> None:
"""Populate the security context (called by the middleware)."""
_claims_var.set(claims)
_roles_var.set(roles)
_groups_var.set(claims.groups)
|
clear() staticmethod
Clear the security context (called by the middleware in finally).
Source code in src/pico_client_auth/security_context.py
| @staticmethod
def clear() -> None:
"""Clear the security context (called by the middleware in ``finally``)."""
_claims_var.set(None)
_roles_var.set([])
_groups_var.set(())
|
allow_anonymous(fn)
Mark an endpoint as accessible without authentication.
When applied to a controller method, the auth middleware will skip token validation for that route.
Source code in src/pico_client_auth/decorators.py
| def allow_anonymous(fn: F) -> F:
"""Mark an endpoint as accessible without authentication.
When applied to a controller method, the auth middleware will skip
token validation for that route.
"""
setattr(fn, PICO_ALLOW_ANONYMOUS, True)
return fn
|
requires_group(*group_ids)
Require the authenticated user to belong to at least one of the specified groups.
Parameters:
| Name | Type | Description | Default |
*group_ids | str | One or more group IDs. The user must belong to at least one. | () |
Returns:
| Type | Description |
Callable[[F], F] | A decorator that attaches group metadata to the endpoint. |
Source code in src/pico_client_auth/decorators.py
| def requires_group(*group_ids: str) -> Callable[[F], F]:
"""Require the authenticated user to belong to at least one of the specified groups.
Args:
*group_ids: One or more group IDs. The user must belong to at least one.
Returns:
A decorator that attaches group metadata to the endpoint.
"""
def decorator(fn: F) -> F:
setattr(fn, PICO_REQUIRED_GROUPS, frozenset(group_ids))
return fn
return decorator
|
requires_role(*roles)
Require the authenticated user to have at least one of the specified roles.
Parameters:
| Name | Type | Description | Default |
*roles | str | One or more role names. The user must have at least one. | () |
Returns:
| Type | Description |
Callable[[F], F] | A decorator that attaches role metadata to the endpoint. |
Source code in src/pico_client_auth/decorators.py
| def requires_role(*roles: str) -> Callable[[F], F]:
"""Require the authenticated user to have at least one of the specified roles.
Args:
*roles: One or more role names. The user must have at least one.
Returns:
A decorator that attaches role metadata to the endpoint.
"""
def decorator(fn: F) -> F:
setattr(fn, PICO_REQUIRED_ROLES, frozenset(roles))
return fn
return decorator
|
any_scope_matches(granted_scopes, required_scopes)
Return True if ANY granted scope matches ANY required scope.
granted_scopes is the set declared by the agent's JWT. required_scopes is the set declared by @requires_scope.
Source code in src/pico_client_auth/scope.py
| def any_scope_matches(granted_scopes, required_scopes) -> bool:
"""Return True if ANY granted scope matches ANY required scope.
`granted_scopes` is the set declared by the agent's JWT.
`required_scopes` is the set declared by `@requires_scope`."""
for r in required_scopes:
for g in granted_scopes:
if scope_matches(g, r):
return True
return False
|
requires_scope(*scopes)
Mark an endpoint as requiring at least one of the given scopes on the agent token (X-Agent-Authorization).
Parameters:
| Name | Type | Description | Default |
*scopes | str | One or more scope strings. The agent must declare at least one matching scope (exact or via wildcard expansion — see scope_matches). | () |
Example::
@controller(prefix="/api/v1/internal/treasury")
class TreasuryController:
@post("/budget/draft")
@requires_role("treasury_writer")
@requires_scope("treasury:write:budget:opex")
async def draft_budget(self, body: dict): ...
Source code in src/pico_client_auth/scope.py
| def requires_scope(*scopes: str) -> Callable[[F], F]:
"""Mark an endpoint as requiring at least one of the given scopes
on the agent token (X-Agent-Authorization).
Args:
*scopes: One or more scope strings. The agent must declare at
least one matching scope (exact or via wildcard
expansion — see `scope_matches`).
Example::
@controller(prefix="/api/v1/internal/treasury")
class TreasuryController:
@post("/budget/draft")
@requires_role("treasury_writer")
@requires_scope("treasury:write:budget:opex")
async def draft_budget(self, body: dict): ...
"""
def decorator(fn: F) -> F:
setattr(fn, PICO_REQUIRED_SCOPES, frozenset(scopes))
return fn
return decorator
|
scope_matches(granted, required)
Return True if granted satisfies required.
Match rules
- Exact:
a:b:c matches a:b:c. - Wildcard (right edge):
a:b:* matches a:b:c and a:b:c:d (anything under the prefix), but NOT a:b. - Wildcard (sole):
* matches anything. - Mid-segment wildcards (
a:*:c) are NOT supported — keep the matcher simple and predictable.
Source code in src/pico_client_auth/scope.py
| def scope_matches(granted: str, required: str) -> bool:
"""Return True if `granted` satisfies `required`.
Match rules:
- Exact: ``a:b:c`` matches ``a:b:c``.
- Wildcard (right edge): ``a:b:*`` matches ``a:b:c`` and
``a:b:c:d`` (anything under the prefix), but NOT ``a:b``.
- Wildcard (sole): ``*`` matches anything.
- Mid-segment wildcards (``a:*:c``) are NOT supported — keep
the matcher simple and predictable.
"""
if granted == required:
return True
if granted == "*":
return True
if granted.endswith(":*"):
prefix = granted[:-2]
return required == prefix or required.startswith(prefix + ":")
return False
|